From 16fe6b1dfec2fbc68ca018178827c459157934c8 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 29 Jul 2026 19:20:51 -0400 Subject: [PATCH 001/203] fix(lib): detect Graphite from repo metadata, not gt on PATH --- git-workon-lib/src/stack.rs | 40 ++++++++++++++++++++++++++++++++++--- git-workon/src/cmd/new.rs | 6 ++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/git-workon-lib/src/stack.rs b/git-workon-lib/src/stack.rs index 006057a..70e9041 100644 --- a/git-workon-lib/src/stack.rs +++ b/git-workon-lib/src/stack.rs @@ -71,10 +71,17 @@ pub enum StackModel { impl StackModel { /// Auto-detect the active stack model from the repository environment. /// - /// Returns [`StackModel::Graphite`] when `gt` is on PATH **and** the repo has been - /// initialized with `gt init` (`.graphite_repo_config` exists). Otherwise returns + /// Returns [`StackModel::Graphite`] when the repo has been initialized with `gt init` + /// (`.graphite_repo_config` or `.graphite_metadata.db` exists). Otherwise returns /// [`StackModel::None`]. /// + /// Deliberately does NOT consult [`graphite::detect_gt`]: the repo's own metadata is the + /// ground truth for "is this a Graphite stack," and reading it is pure libgit2 (see the + /// module docs — no `gt` process is needed for detection or visualization). Gating on the + /// binary's presence would report `None` for a genuine Graphite stack whenever `gt` happens + /// to be missing from PATH, silently emptying the review TUI's stack. Whether `gt` can be + /// *executed* is a separate question, and belongs to the call sites that execute it. + /// /// Never returns [`StackModel::Git`]: auto-detection only distinguishes "a stack tool is /// active" from "no stack tool," since CLI routing treats any non-`None` model as /// stack-active. Auto-resolving to `Git` for every repository with an upstream-tracking @@ -82,7 +89,7 @@ impl StackModel { /// explicit `workon.stackModel = git` config, or a caller mapping `None` to `Git` before /// calling [`crate::assemble_changesets`] (the review crate does this from M3 onward). pub fn detect(repo: &Repository) -> Self { - if graphite::detect_gt() && graphite::is_graphite_repo(repo) { + if graphite::is_graphite_repo(repo) { Self::Graphite } else { Self::None @@ -241,6 +248,33 @@ pub fn group_by_stack(stacks: &[Option]) -> StackGrouping { #[cfg(test)] mod tests { use super::*; + use git_workon_fixture::prelude::*; + + /// `detect` must resolve `Graphite` from repo metadata alone, with no dependency on whether + /// `gt` is installed on the host running the tests. + /// + /// The "gt absent" half cannot be forced from inside this process: it would mean mutating + /// `PATH`, which is process-global (`unsafe` under Rust 2024) and would race every other test + /// in this binary. Only the CLI suite can scrub `PATH` hermetically, by passing it to a child + /// process (`git-workon/tests/suite/new.rs`'s `path_without_gt_new`). So the guard here is + /// environmental rather than hermetic — CI has no `gt`, so a reintroduced `detect_gt()` gate + /// turns this red there. Its value is making that failure say "detection must not require gt" + /// instead of surfacing as a handful of unrelated review-TUI changeset-count assertions. + #[test] + fn detect_resolves_graphite_from_repo_metadata_without_requiring_the_gt_binary() { + let fixture = FixtureBuilder::new() + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + + assert_eq!( + StackModel::detect(repo), + StackModel::Graphite, + "a gt-initialized repo is a Graphite stack regardless of PATH" + ); + } fn stack(trunk: &str, diffs: &[&str], current: &str) -> Stack { Stack { diff --git a/git-workon/src/cmd/new.rs b/git-workon/src/cmd/new.rs index be25f4f..e52f4f0 100644 --- a/git-workon/src/cmd/new.rs +++ b/git-workon/src/cmd/new.rs @@ -321,6 +321,12 @@ impl Run for New { .wrap_err(format!("Failed to create worktree '{}'", effective_branch))?; // Register the new branch with gt when stack-active (non-fatal on failure). + // + // Deliberately NOT guarded on `detect_gt()`: `StackModel::detect` resolves `Graphite` + // from repo metadata alone, so this can run on a machine without `gt`. The resulting + // "gt track unavailable" warning is the point — the new branch really is untracked, and + // silence would hide that until the stack looked wrong later. See + // `new_gt_track_failure_is_non_fatal`. if effective_model == StackModel::Graphite && !self.no_stack && !branch_pre_existed From 770d387bc13039585395b7fdc8ffa3c7cb1b66e8 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sun, 5 Jul 2026 15:38:07 -0400 Subject: [PATCH 002/203] feat(review): scaffold git-workon-review lib+bin crate --- Cargo.lock | 1272 ++++++++++++++++++- Cargo.toml | 6 +- git-workon-review/Cargo.toml | 53 + git-workon-review/README.md | 7 + git-workon-review/src/error.rs | 14 + git-workon-review/src/lib.rs | 11 + git-workon-review/src/main.rs | 18 + git-workon-review/tests/cli.rs | 19 + git-workon-review/tests/treesitter_smoke.rs | 43 + 9 files changed, 1411 insertions(+), 32 deletions(-) create mode 100644 git-workon-review/Cargo.toml create mode 100644 git-workon-review/README.md create mode 100644 git-workon-review/src/error.rs create mode 100644 git-workon-review/src/lib.rs create mode 100644 git-workon-review/src/main.rs create mode 100644 git-workon-review/tests/cli.rs create mode 100644 git-workon-review/tests/treesitter_smoke.rs diff --git a/Cargo.lock b/Cargo.lock index 50d7fc0..52f9a2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -76,6 +82,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "assert_cmd" version = "2.2.2" @@ -107,6 +128,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "auth-git2" version = "0.6.0" @@ -148,6 +178,27 @@ dependencies = [ "backtrace", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.3.2" @@ -156,9 +207,18 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] [[package]] name = "bstr" @@ -177,6 +237,27 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.65" @@ -191,9 +272,15 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "clap" @@ -276,6 +363,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "conpty" version = "0.5.1" @@ -298,6 +399,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -323,6 +448,121 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "dialoguer" version = "0.12.0" @@ -342,6 +582,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -374,6 +624,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -403,6 +668,12 @@ dependencies = [ "log", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -413,6 +684,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "expectrl" version = "0.9.0" @@ -420,7 +700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e0df3044b2257277f573d1e40912ebd4d5891f7588640e3125cbbbb24ff7be3" dependencies = [ "conpty", - "nix", + "nix 0.26.4", "ptyprocess", "regex", ] @@ -437,18 +717,57 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "float-cmp" version = "0.10.0" @@ -458,6 +777,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -517,6 +842,16 @@ dependencies = [ "thread_local", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.10" @@ -617,7 +952,25 @@ dependencies = [ "rusqlite", "serde_json", "serial_test", - "thiserror", + "thiserror 2.0.19", +] + +[[package]] +name = "git-workon-review" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "clap", + "git-workon-fixture", + "git-workon-lib", + "git2", + "miette", + "predicates", + "ratatui", + "thiserror 2.0.19", + "tree-sitter", + "tree-sitter-highlight", + "tree-sitter-rust", ] [[package]] @@ -626,7 +979,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "libc", "libgit2-sys", "log", @@ -660,7 +1013,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "ignore", "walkdir", ] @@ -671,6 +1024,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -680,6 +1035,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -698,6 +1055,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "icu_collections" version = "2.0.0" @@ -784,6 +1147,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -821,6 +1190,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "indicatif" version = "0.18.5" @@ -834,6 +1213,28 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "is_ci" version = "1.2.0" @@ -855,6 +1256,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -905,6 +1315,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.19", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -925,6 +1358,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.17" @@ -971,6 +1410,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -983,6 +1431,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -998,12 +1452,37 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + [[package]] name = "memoffset" version = "0.7.1" @@ -1013,6 +1492,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miette" version = "7.6.0" @@ -1043,6 +1531,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.7.1" @@ -1052,6 +1546,18 @@ dependencies = [ "adler", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nix" version = "0.26.4" @@ -1061,16 +1567,56 @@ dependencies = [ "bitflags 1.3.2", "cfg-if", "libc", - "memoffset", + "memoffset 0.7.1", "pin-utils", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset 0.9.1", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-traits" version = "0.2.15" @@ -1080,6 +1626,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.32.1" @@ -1135,12 +1690,45 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "owo-colors" version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1176,6 +1764,100 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1218,6 +1900,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "predicates" version = "3.1.4" @@ -1263,29 +1951,145 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "101be273c0b1680d7056afddbaa88f02b6e9f2dc161165c30bee9914b6025a79" dependencies = [ - "nix", + "nix 0.26.4", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.19", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.2", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", ] [[package]] -name = "quote" -version = "1.0.45" +name = "ratatui-termina" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" dependencies = [ - "proc-macro2", + "instability", + "ratatui-core", + "termina", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "ratatui-termwiz" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] [[package]] -name = "r-efi" -version = "6.0.0" +name = "ratatui-widgets" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width 0.2.2", +] [[package]] name = "redox_syscall" @@ -1293,7 +2097,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] @@ -1304,7 +2108,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.10", "libredox", - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -1349,7 +2153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -1358,7 +2162,7 @@ version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1373,13 +2177,22 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -1413,11 +2226,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" -version = "1.0.171" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30e27d1e4fd7659406c492fd6cfaf2066ba8773de45ca75e855590f856dc34a9" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] [[package]] name = "serde_core" @@ -1441,14 +2264,16 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.144" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56177480b00303e689183f110b4e727bb4211d692c62d4fcd16d02be93077d40" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", - "ryu", + "serde", "serde_core", + "zmij", ] [[package]] @@ -1476,6 +2301,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shell-words" version = "1.1.0" @@ -1488,6 +2324,43 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1518,12 +2391,45 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "supports-color" version = "3.0.2" @@ -1545,6 +2451,17 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -1591,6 +2508,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminal-prompt" version = "0.2.3" @@ -1611,12 +2541,75 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "textwrap" version = "0.16.1" @@ -1627,13 +2620,33 @@ dependencies = [ "unicode-width 0.1.11", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1657,6 +2670,27 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.1" @@ -1667,6 +2701,60 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tree-sitter" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1c71c1c4cc0920b20d6b0f6572e7682cd07a6a2faec71067a31fa394c586df" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-highlight" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd7a0c48d503cf4e0a57a2453424eaef2fce4b4269f13e3579e52f0d0c9e5cc8" +dependencies = [ + "regex", + "streaming-iterator", + "thiserror 2.0.19", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" @@ -1685,6 +2773,23 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "unicode-width" version = "0.1.11" @@ -1726,12 +2831,39 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wait-timeout" version = "0.2.0" @@ -1821,6 +2953,78 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2168,3 +3372,9 @@ dependencies = [ "quote", "syn 2.0.117", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index c2204d1..bb022c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" default-members = ["git-workon"] -members = ["git-workon", "git-workon-lib", "git-workon-fixture"] +members = ["git-workon", "git-workon-lib", "git-workon-fixture", "git-workon-review"] [workspace.package] authors = ["Eric Eldredge "] @@ -45,10 +45,14 @@ supports-color = "3" expectrl = "0.9" fuzzy-matcher = "0.3" pathdiff = "0.2.3" +ratatui = "0.30" rusqlite = { version = "0.40", features = ["bundled"] } serde_json = "1.0" serial_test = "3" thiserror = "2.0.18" +tree-sitter = "0.26" +tree-sitter-highlight = "0.26" +tree-sitter-rust = "0.24" unicode-width = "0.2.2" # The profile that 'dist' will build with diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml new file mode 100644 index 0000000..2f9c731 --- /dev/null +++ b/git-workon-review/Cargo.toml @@ -0,0 +1,53 @@ +[package] +authors.workspace = true +categories = ["command-line-utilities", "development-tools"] +description = "TUI for reviewing changesets" +edition.workspace = true +homepage.workspace = true +keywords = ["cli", "git", "review", "tui", "workon"] +license.workspace = true +name = "git-workon-review" +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version = "0.1.0" +include = [ + "src/**/*", + "Cargo.toml", + "LICENSE*", + "README.md", +] +# Not yet published to crates.io: flip to publish this crate at M3 (per RFC). +# At that point: remove this line, add `[[package]] name = "git-workon-review"` +# to release-plz.toml (no version_group — versioned independently of the CLI), +# and decide the `dist = false` posture below (the homebrew patch step in +# .github/workflows/release.yml stamps man/completions into every +# Formula/*.rb and must be reworked before this binary can be distributed). +publish = false + +[lib] +name = "workon_review" + +[features] +vendored = ["git-workon-lib/vendored", "git2/vendored-libgit2", "git2/vendored-openssl"] + +[dependencies] +clap.workspace = true +git-workon-lib.workspace = true +git2.workspace = true +miette.workspace = true +ratatui.workspace = true +thiserror.workspace = true + +[package.metadata.dist] +# Redundant with publish = false today; load-bearing at the M3 flip so +# cargo-dist doesn't silently start shipping the (still undesigned) binary. +dist = false + +[dev-dependencies] +assert_cmd.workspace = true +git-workon-fixture.workspace = true +predicates.workspace = true +tree-sitter.workspace = true +tree-sitter-highlight.workspace = true +tree-sitter-rust.workspace = true diff --git a/git-workon-review/README.md b/git-workon-review/README.md new file mode 100644 index 0000000..77355d3 --- /dev/null +++ b/git-workon-review/README.md @@ -0,0 +1,7 @@ +# git-workon-review + +A standalone TUI for reviewing changesets — any branch/ref/range/stack. + +This crate is scaffolding (M0): the binary builds and prints help, but no +review functionality exists yet. See `docs/rfc/workon-review.md` in the +workspace root for the full design. diff --git a/git-workon-review/src/error.rs b/git-workon-review/src/error.rs new file mode 100644 index 0000000..e61259e --- /dev/null +++ b/git-workon-review/src/error.rs @@ -0,0 +1,14 @@ +use miette::Diagnostic; +use thiserror::Error; + +/// Result type alias using ReviewError +pub type Result = std::result::Result; + +/// Main error type for the review library +#[derive(Error, Diagnostic, Debug)] +pub enum ReviewError { + /// Git operation failed + #[error(transparent)] + #[diagnostic(code(workon::review::git_error))] + Git(#[from] git2::Error), +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs new file mode 100644 index 0000000..f76815a --- /dev/null +++ b/git-workon-review/src/lib.rs @@ -0,0 +1,11 @@ +//! Core library for `git-workon-review`, a TUI for reviewing changesets. +//! +//! This library (lib target `workon_review`) will hold the review domain: +//! diff parsing, word-diff, line-precise staging, and changeset views. See +//! `docs/rfc/workon-review.md` in the workspace root for the full design. +//! +//! ## Status +//! +//! M0 scaffolding only — no review logic exists yet. + +pub mod error; diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs new file mode 100644 index 0000000..d7a3831 --- /dev/null +++ b/git-workon-review/src/main.rs @@ -0,0 +1,18 @@ +use clap::Parser; + +/// A TUI for reviewing changesets +#[derive(Debug, Parser)] +#[clap( + about, + author, + bin_name = env!("CARGO_PKG_NAME"), + version, + arg_required_else_help = true +)] +struct Cli {} + +fn main() -> miette::Result<()> { + Cli::parse(); + + Ok(()) +} diff --git a/git-workon-review/tests/cli.rs b/git-workon-review/tests/cli.rs new file mode 100644 index 0000000..c621cf2 --- /dev/null +++ b/git-workon-review/tests/cli.rs @@ -0,0 +1,19 @@ +use assert_cmd::cargo_bin_cmd; +use predicates::prelude::*; + +#[test] +fn no_args_shows_usage_and_fails() { + let mut cmd = cargo_bin_cmd!("git-workon-review"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("Usage")); +} + +#[test] +fn help_shows_usage_and_succeeds() { + let mut cmd = cargo_bin_cmd!("git-workon-review"); + cmd.arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("git-workon-review")); +} diff --git a/git-workon-review/tests/treesitter_smoke.rs b/git-workon-review/tests/treesitter_smoke.rs new file mode 100644 index 0000000..e8acfb2 --- /dev/null +++ b/git-workon-review/tests/treesitter_smoke.rs @@ -0,0 +1,43 @@ +//! Proves the C-compilation path for tree-sitter grammars works end-to-end +//! in CI on all platforms: parse a snippet and run one highlight pass. + +use tree_sitter::Parser; +use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter}; + +#[test] +fn parses_rust_snippet_without_errors() { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_rust::LANGUAGE.into()) + .expect("rust grammar loads"); + + let tree = parser.parse("fn main() {}", None).expect("parses"); + assert!(!tree.root_node().has_error()); +} + +#[test] +fn highlight_pass_emits_at_least_one_highlight_start() { + let mut config = HighlightConfiguration::new( + tree_sitter_rust::LANGUAGE.into(), + "rust", + tree_sitter_rust::HIGHLIGHTS_QUERY, + "", + "", + ) + .expect("highlight configuration builds"); + config.configure(&["keyword", "function"]); + + let mut highlighter = Highlighter::new(); + let events = highlighter + .highlight(&config, b"fn main() {}", None, |_| None) + .expect("highlighting succeeds"); + + let saw_highlight_start = events + .filter_map(|event| event.ok()) + .any(|event| matches!(event, HighlightEvent::HighlightStart(_))); + + assert!( + saw_highlight_start, + "expected at least one HighlightStart event" + ); +} From e79a5496d95dc9707d2f127bf5acf37c0fd91ef9 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sun, 5 Jul 2026 15:38:11 -0400 Subject: [PATCH 003/203] docs: record review crate workspace placement in ADR-027 --- CLAUDE.md | 8 ++- docs/INDEX.md | 6 ++ .../027-review-crate-workspace-placement.md | 30 +++++++++ docs/rfc/workon-review.md | 62 +++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 docs/adr/027-review-crate-workspace-placement.md create mode 100644 docs/rfc/workon-review.md diff --git a/CLAUDE.md b/CLAUDE.md index 56bc8ab..46ed405 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,11 +8,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Workspace Structure -This is a Cargo workspace with three crates: +This is a Cargo workspace with four crates: - **git-workon** (git-workon/): The CLI binary that provides the user-facing commands - **git-workon-lib** (git-workon-lib/): Core library (published as `workon`) containing the git worktree manipulation logic - **git-workon-fixture** (git-workon-fixture/): Testing utilities that provide fixture builders and custom predicates for git repository tests +- **git-workon-review** (git-workon-review/): Lib+bin crate for the review TUI domain — diff parsing, staging, changeset views; the binary is the TUI ## File Location Quick Reference @@ -32,6 +33,9 @@ This is a Cargo workspace with three crates: - Add integration tests → `git-workon-lib/tests/` or `git-workon/tests/` - Find workon root logic → `git-workon-lib/src/workon_root.rs` - Smart routing logic → `git-workon/src/main.rs` (lines 20-38) +- Add review domain logic → `git-workon-review/src/` +- Add review CLI entry → `git-workon-review/src/main.rs` +- Add review error types → `git-workon-review/src/error.rs` (ADR-008 pattern: concrete enums with `#[derive(Error, Diagnostic)]`) ## Key Architecture Concepts @@ -73,7 +77,7 @@ Inline test/clippy runs go through `cargo-gate test`/`clippy` (a raw `cargo test **Valid types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` -**Scopes** (optional): `cli`, `lib`, `fixture`, `config`, `worktree`, `hooks`, `copy`, `pr`, `completions`, `build`, `release` +**Scopes** (optional): `cli`, `lib`, `fixture`, `review`, `config`, `worktree`, `hooks`, `copy`, `pr`, `completions`, `build`, `release` **Breaking changes**: append `!` — e.g. `feat(cli)!: change output format` diff --git a/docs/INDEX.md b/docs/INDEX.md index cf0e090..e404659 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -56,6 +56,12 @@ Maps subsystems and topics to relevant documentation files and source paths. Use - `docs/adr/009-pr-workflow-gh-cli.md` - Key source: `git-workon-lib/src/pr.rs`, `git-workon/src/cmd/new.rs` +### review / workon-review + +- `docs/rfc/workon-review.md` +- `docs/adr/027-review-crate-workspace-placement.md` +- Key source: `git-workon-review/src/` + ## Cross-cutting Concerns ### errors / error-handling / miette diff --git a/docs/adr/027-review-crate-workspace-placement.md b/docs/adr/027-review-crate-workspace-placement.md new file mode 100644 index 0000000..3057b3b --- /dev/null +++ b/docs/adr/027-review-crate-workspace-placement.md @@ -0,0 +1,30 @@ +# 027 — Review Crate Workspace Placement + +## Context + +The RFC (`docs/rfc/workon-review.md`) defines `git-workon-review`, a standalone TUI for reviewing changesets, as a fourth sibling crate in this workspace (ADR-003). The existing release pipeline (ADR-020) auto-publishes any new publishable crate on the next `main` push — release-plz's `release` job publishes any registry-unmatched package without waiting for a release PR — and cargo-dist auto-includes any publishable bin crate as a distributed App. Both behaviors are wrong for a crate that starts as an empty scaffold. + +## Decision + +Add `git-workon-review` as a sibling crate: lib target `workon_review`, bin target `git-workon-review`. + +- **`publish = false`** in the crate's `Cargo.toml` is the single knob that keeps it out of both release-plz and cargo-dist, following the `git-workon-fixture` precedent (proven across ~20 releases). +- **`[package.metadata.dist] dist = false`** is set explicitly as well. It is redundant today (`publish = false` already excludes the crate) but is the tripwire for the M3 flip: removing `publish = false` alone, without also deciding this field, would silently make cargo-dist ship the binary. +- **Independent versioning**: the crate does not join `version_group = "main"` in `release-plz.toml`. Joining would lockstep its version to the CLI's and cross-bump the CLI on every review-crate change. +- **Workspace `rust-version` bumped to `1.88`** (ratatui 0.30's floor). `clap` 4.6 already required 1.85, so the workspace's previous `1.68.2` declaration was already unsatisfiable in practice; only the fixture crate had ever inherited the field. `rust-version.workspace = true` is added to all four crates so the field is real everywhere. + +## Consequences + +- The crate builds and tests in CI from the start (M0) without appearing in crates.io or in any cargo-dist release artifact. +- The M3 flip (when the review binary is ready to distribute) requires: + 1. Remove `publish = false` from `git-workon-review/Cargo.toml`. + 2. Add `[[package]] name = "git-workon-review"` to `release-plz.toml`, with **no** `version_group` — independent versioning is intentional, not an oversight to fix later. + 3. Keep `dist = false` until binary distribution is designed. The homebrew publish job in `.github/workflows/release.yml` patches **every** `Formula/*.rb` with `git-workon`'s man page and completions install lines; it must be reworked before a second binary can safely flow through it. `release-plz.yml`'s `dist` dispatch step is also hardcoded to fire only for `package_name == "git-workon"` and needs updating too. +- Until the M3 flip, the crate's version in its own `Cargo.toml` is cosmetic — release-plz never touches it. + +## References + +- `docs/rfc/workon-review.md` — RFC defining the review crate +- [ADR-003](003-three-crate-workspace.md) — workspace structure this crate joins +- [ADR-019](019-ci-quality-gates.md) — CI gates the new crate is subject to +- [ADR-020](020-two-tool-release-pipeline.md) — release pipeline whose auto-publish/auto-dist behavior this ADR opts the crate out of diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md new file mode 100644 index 0000000..487623c --- /dev/null +++ b/docs/rfc/workon-review.md @@ -0,0 +1,62 @@ +# git-workon-review: Scaffolding Plan + +Status: **accepted** — decisions below are settled (2026-07-05 design sessions); this doc is the execution plan for scaffolding. +Prior art in this repo: [stacked-diffs.md](./stacked-diffs.md), [agent-integration.md](./agent-integration.md). + +## What it is + +`git-workon-review` is a standalone TUI for reviewing changesets — any branch/ref/range/stack (including reviewing what a coding agent did before it lands). It renders side-by-side diffs with word-level emphasis and tree-sitter syntax highlighting, supports line-precise staging as the accept/reject verb, navigates graphite/git stacks changeset-by-changeset, and can feed review comments back to a coding agent via MCP. It embeds cleanly in an editor terminal (lazygit-style) and runs standalone. + +It is the productization of a working Neovim prototype (`~/.config/nvim/lua/app/review/`, ~6k lines Lua, feature-complete through line-precise staging). The prototype is **frozen** (bug fixes only); new features land here first. A renderer spike (`~/Code/review-tui-spike`) validated the ratatui approach — port its modules, don't depend on it. + +## Decision log + +| Decision | Outcome | +|---|---| +| Positioning | Changeset review tool; not a lazygit competitor. Comments-to-agent is a first-class capability, not a stretch. | +| Home | This workspace, as sibling crate `git-workon-review`. | +| Crate layout | ONE crate, lib+bin targets. lib = review domain (diff parse, word-diff, staging, changeset views); bin = TUI + `mcp` subcommand. No separate core crate until a second consumer exists. | +| Name | Package == binary == `git-workon-review`. `git workon-review` works via git's native `git-*` dispatch. (`git-review` is squatted on crates.io + Gerrit-loaded; `docket` too docker-adjacent; bare `review` superseded by suite framing; `signoff` was the free runner-up.) | +| `git-workon review` dispatch | `git-workon` adds cargo-style external-subcommand dispatch: unknown subcommand → exec `git-workon-` on PATH, args passed through. | +| NO `workon` binary | Deliberate: Python virtualenvwrapper keeps the `workon` name. Do not re-propose. | +| Git substrate | git2 throughout, aligned with git-workon-lib. Consequence: the prototype's patch/staging semantics were validated against git CLI — must re-verify against libgit2 (see Trap corpus). Escape hatch if libgit2 apply diverges: shell out to `git apply` for writes only. | +| Stack capabilities | All three land in **git-workon-lib** (not the review crate): (1) needs-restack via `parentBranchRevision` (present in both metadata formats, currently unread), (2) git-inference StackModel for metadata-less repos (in-flight semantics: upstream..HEAD per-commit changesets), (3) changeset assembly (base..head pairs + uncommitted layer + focus). Lib stays diff-free. | +| Lib hygiene | Remove unused `dialoguer`/`env_logger` from git-workon-lib deps; optionally feature-gate the network stack (clone/fetch/auth-git2 behind default-on `network` feature). | +| Fixture | `git-workon-fixture` is the test substrate for both crates. Extend it: SQLite-format graphite metadata mode (the sqlite read path is currently fixture-untested — builder only writes legacy refs blobs) and index-state builders (staged/unstaged/untracked combos). | +| Highlighting | tree-sitter (tree-sitter-highlight), syntect as long-tail fallback. Measured: ts ~0.01ms/line vs syntect ~0.19ms/line, and better output. Grammar set + gotchas are in the spike. | +| View model | Full parity with the prototype's four zoom states (split/combined/unstaged/staged + attributed rendering). If v1 must shrink, cut zoom states — never the comments loop. | +| v1 sources | uncommitted, stack, ref/range. PR deferred (git-workon-lib's `pr.rs` covers much of it later). | +| Comments | MCP: on-disk comment store (`.review/` JSON or sqlite) + `git-workon-review mcp` stdio subcommand serving get/resolve tools; TUI watches the store. Degrades to a plain file convention for non-MCP harnesses. | +| Edit flow | Embedded: `nvim --server $NVIM --remote + `. Standalone: `$EDITOR`. File watcher refreshes on save. | +| Completions | Full clap_complete (unstable-dynamic, already a workspace dep) on the direct binary. Work item: git-workon's dynamic completer enumerates `git-workon-*` on PATH and delegates post-subcommand completion via `COMPLETE= git-workon-review -- `. Git-level shims: on demand only. | +| Study first | `jjr` crate (agent jj-stack review surface), `triage-tui`, `wb300` — adjacent tools found during naming research. | + +## Reference material + +- **Prototype** (`~/.config/nvim/lua/app/review/`): the behavioral spec. Key modules: `diff/parser.lua` (hunk parse + patch synthesis — the crown jewels), `staging.lua` (FIFO queue semantics), `docket.lua` (`_gate` zoom matrix, window topology), `source/stack.lua` + `source/graph/` (graphite walk, git fallback, in-flight semantics), `ui/diff.lua` (rendering + attribution), colocated `*_spec.lua` files. E2E harness: `nvim/tests/review/`. +- **Spike** (`~/Code/review-tui-spike`): port `align.rs` (SBS row pairing + parity invariant), `wordiff.rs` (similar-based spans), `highlight_ts.rs` (grammar set, theme, per-line span splitting; JS exports `HIGHLIGHT_QUERY` singular + separate JSX query; TS/TSX queries concatenate TS-specific-first), `ui.rs` (viewport-sliced rendering), `diff.rs` (parser fallback to `diff --git` header for binary files). Bench mode worth keeping. + +## Trap corpus (port as tests FIRST — none of this is guessable) + +Hard-won semantics from the prototype, all of which caused real bugs. Each becomes a test before its feature is implemented: + +1. **Patch direction rules**: synthesizing a partial patch (line-precise staging) has direction-dependent drop rules. Forward apply (stage): dropped adds omitted, dropped dels → context. Reverse apply (unstage `--cached --reverse`, discard `--reverse`): dropped adds → context, dropped dels omitted — git rejects any partial selection otherwise. Round-trip test both directions + a tripwire asserting forward rules do NOT reverse-apply. +2. **No-newline EOF corruption (silent!)**: a dropped del converted to context carrying the `\ No newline at end of file` marker, followed by a kept add, is ACCEPTED by git apply (exit 0) which concatenates the add onto the no-newline line — corrupt blob, no error. Fix: splice into del+re-add form when kept lines follow. Assert the exact blob bytes. +3. **Whole-file ops for A/D/U statuses**: hunk-level patches can't express creations/deletions (untracked hunk-stage errors; deleted-file hunk-stage stages an EMPTY BLOB). Fall back to file-level ops; line-selection on these REFUSES with a notify. +4. **Staging queue**: FIFO, op stays queued while in flight (remove-before-run double-runs); ops resolve direction from the LIVE index inside the queued op, never from a snapshot (stale-snapshot toggles silently no-op); retry once on `index.lock` contention (~100ms); pcall/catch around ops (a sync throw deadlocks the queue). +5. **Refresh generation/livelock**: refreshes carry a generation seq; a superseded completion must re-snapshot the index signature BEFORE the supersede check returns, or its own diff's stat-cache rewrite echoes into the index watcher and livelocks refresh forever under staging storms. +6. **git2 re-verification**: all of the above were validated against git CLI. Re-run the round-trip corpus against libgit2's apply/index. Divergence → shell out to `git apply` for writes (reads stay git2). + +## Milestones + +- **M0 — workspace plumbing.** New member crate `git-workon-review` (lib+bin, clap, error model matching workspace: thiserror+miette). Toolchain bump (ratatui/tree-sitter won't meet 1.68.2; resolved: workspace-wide `rust-version = 1.88` — no crate had ever inherited the old value, so there was no lib MSRV to preserve). Lib hygiene (drop unused dialoguer/env_logger). CI: tree-sitter C builds. Release posture per [ADR-027](../adr/027-review-crate-workspace-placement.md): `publish = false` keeps the crate out of release-plz and cargo-dist entirely; release-plz wiring is deliberately deferred to the M3 flip — do NOT add a release-plz.toml entry in M0. Acceptance: `cargo build --workspace` green, empty `git-workon-review` binary runs and prints help. +- **M1 — fixture extensions + lib stack capabilities (test-first).** Fixture: sqlite metadata mode (also finally exercises the lib's primary read path), index-state builders. Lib: `parentBranchRevision` read (both formats) + needs-restack; git-inference StackModel; changeset assembly API (`Vec {branch, base_ref, head_ref, title, current, needs_restack}` + uncommitted layer). Acceptance: existing lib tests green + new capabilities spec'd against fixtures in both metadata formats. +- **M2 — trap corpus port.** Diff parser + patch synthesis in the review lib, the six trap items as tests, git2-vs-CLI verdict rendered (and the write-path decision recorded here). Acceptance: round-trip corpus green against real repos. +- **M3 — renderer + uncommitted source.** Port spike modules; wire changeset → parsed diff → SBS/inline render; file nav; the uncommitted source end-to-end. Acceptance: dogfood-able read-only review of a dirty worktree. +- **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. +- **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). +- **M6 — comments + integration.** Comment store + `mcp` subcommand; `$NVIM`/`$EDITOR` edit jump; git-workon external dispatch + completion delegation. Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review. + +## Orchestration notes + +Main-thread implementation; subagents only for explore/plan/code-review fan-out. Model tiers: design-heavy work on the strongest model; well-understood ports (M2 corpus, M3 spike port) delegate well to mid-tier; mechanical work (fixture builders, CI wiring) to the fast tier. Review each milestone (`/code-review`) before landing; run the full workspace test suite per milestone, not per commit. From 1e8d9e47da505b49f88fb1fe95ed968d8fe106de Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sun, 5 Jul 2026 17:48:49 -0400 Subject: [PATCH 004/203] docs(review): add stale-metadata-head trap to RFC corpus --- docs/rfc/workon-review.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 487623c..d79c6b5 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -46,6 +46,7 @@ Hard-won semantics from the prototype, all of which caused real bugs. Each becom 4. **Staging queue**: FIFO, op stays queued while in flight (remove-before-run double-runs); ops resolve direction from the LIVE index inside the queued op, never from a snapshot (stale-snapshot toggles silently no-op); retry once on `index.lock` contention (~100ms); pcall/catch around ops (a sync throw deadlocks the queue). 5. **Refresh generation/livelock**: refreshes carry a generation seq; a superseded completion must re-snapshot the index signature BEFORE the supersede check returns, or its own diff's stat-cache rewrite echoes into the index watcher and livelocks refresh forever under staging storms. 6. **git2 re-verification**: all of the above were validated against git CLI. Re-run the round-trip corpus against libgit2's apply/index. Divergence → shell out to `git apply` for writes (reads stay git2). +7. **Metadata revisions are snapshots, not refs** (found dogfooding the prototype on this repo, 2026-07-05): graphite's `branch_revision` updates only when gt runs — commits made with plain git (i.e. any commit made outside gt) leave it stale. The prototype used it as the changeset head, so a freshly-committed branch rendered an EMPTY changeset (`head_rev == parent_rev ==` fork point) while still appearing in the stack. Changeset head must resolve the live ref (`refs/heads/`); `parentBranchRevision` remains the correct BASE (diff-as-authored + needs-restack input) — do not "fix" it to live trunk. Related: the prototype swallows per-changeset diff errors into an empty file list — a failed diff must be distinguishable from a genuinely empty changeset. Test: fixture branch tracked in metadata, then commits added with plain git; assert the changeset spans fork..live-head and that a bad ref surfaces an error, not an empty changeset. ## Milestones From cdc8b8b8ff46ea2b05d8f2ff450551e24bb9651e Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 00:08:09 -0400 Subject: [PATCH 005/203] feat(review): build diff model from git2 diffs --- git-workon-review/src/acquire.rs | 96 ++++++ git-workon-review/src/error.rs | 25 ++ git-workon-review/src/lib.rs | 11 +- git-workon-review/src/model.rs | 236 +++++++++++++++ git-workon-review/tests/diff_model.rs | 409 ++++++++++++++++++++++++++ 5 files changed, 773 insertions(+), 4 deletions(-) create mode 100644 git-workon-review/src/acquire.rs create mode 100644 git-workon-review/src/model.rs create mode 100644 git-workon-review/tests/diff_model.rs diff --git a/git-workon-review/src/acquire.rs b/git-workon-review/src/acquire.rs new file mode 100644 index 0000000..30512e9 --- /dev/null +++ b/git-workon-review/src/acquire.rs @@ -0,0 +1,96 @@ +//! Acquiring a [`DiffModel`] for a resolved rev pair or the live worktree, and routing a +//! [`workon::Changeset`] to the right one. +//! +//! Stays deliberately thin: [`workon::assemble_changesets`] already resolved *what* to diff +//! (a committed rev pair, or "uncommitted"); this module only knows *how* to turn that into +//! git2 diffs and then a [`DiffModel`]. + +use git2::{DiffOptions, Oid, Repository}; +use workon::{Changeset, ChangesetSource}; + +use crate::error::DiffError; +use crate::model::DiffModel; + +/// The two working-tree diffs a review session needs: the index against `HEAD` (staged), and +/// the working tree against the index (unstaged, including untracked content). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeDiffs { + pub staged: DiffModel, + pub unstaged: DiffModel, +} + +/// Diff `HEAD`'s tree against the index (staged) and the index against the working tree +/// (unstaged), for a [`ChangesetSource::Uncommitted`] changeset. +/// +/// The unstaged side sets `include_untracked`/`recurse_untracked_dirs`/ +/// `show_untracked_content` so untracked files carry real content in the model (git2 gives +/// `Delta::Untracked` natively here — no `/dev/null` header synthesis needed). +pub fn diff_uncommitted(repo: &Repository) -> Result { + let head_tree = repo.head()?.peel_to_tree()?; + + let mut staged_opts = DiffOptions::new(); + staged_opts.context_lines(3); + let staged_diff = repo.diff_tree_to_index(Some(&head_tree), None, Some(&mut staged_opts))?; + let staged = DiffModel::from_git2(&staged_diff)?; + + let mut unstaged_opts = DiffOptions::new(); + unstaged_opts + .include_untracked(true) + .recurse_untracked_dirs(true) + .show_untracked_content(true) + .context_lines(3); + let unstaged_diff = repo.diff_index_to_workdir(None, Some(&mut unstaged_opts))?; + let unstaged = DiffModel::from_git2(&unstaged_diff)?; + + Ok(WorktreeDiffs { staged, unstaged }) +} + +/// Diff `base`'s tree against `head`'s tree, for a [`ChangesetSource::Committed`] changeset — +/// rename/copy detection runs via [`git2::Diff::find_similar`] so renamed files come back as +/// [`crate::model::FileStatus::Renamed`] instead of a delete+add pair. +pub fn diff_committed(repo: &Repository, base: Oid, head: Oid) -> Result { + let base_tree = repo.find_commit(base)?.tree()?; + let head_tree = repo.find_commit(head)?.tree()?; + + let mut opts = DiffOptions::new(); + opts.context_lines(3); + let mut diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts))?; + diff.find_similar(None)?; + + DiffModel::from_git2(&diff) +} + +/// The diff for one [`Changeset`], shaped by its [`ChangesetSource`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChangesetDiff { + Committed(DiffModel), + Uncommitted(WorktreeDiffs), +} + +/// Diff `cs`, routing on its [`ChangesetSource`]. +/// +/// A changeset carrying a resolved-but-unreadable rev pair (a bad or garbage `Oid` — e.g. +/// stale Graphite metadata pointing at a pruned commit) is a genuine failure, never an empty +/// [`DiffModel`]: any underlying git2 error is reported as +/// [`DiffError::ChangesetDiffFailed`]. +pub fn diff_changeset(repo: &Repository, cs: &Changeset) -> Result { + match cs.source { + ChangesetSource::Committed { base, head } => diff_committed(repo, base, head) + .map(ChangesetDiff::Committed) + .map_err(|err| changeset_diff_failed(&cs.name, err)), + ChangesetSource::Uncommitted => diff_uncommitted(repo) + .map(ChangesetDiff::Uncommitted) + .map_err(|err| changeset_diff_failed(&cs.name, err)), + } +} + +/// Fold a [`DiffError`] into [`DiffError::ChangesetDiffFailed`], attaching the changeset name. +fn changeset_diff_failed(name: &str, err: DiffError) -> DiffError { + match err { + DiffError::Git(source) => DiffError::ChangesetDiffFailed { + name: name.to_string(), + source, + }, + already_wrapped @ DiffError::ChangesetDiffFailed { .. } => already_wrapped, + } +} diff --git a/git-workon-review/src/error.rs b/git-workon-review/src/error.rs index e61259e..a032f19 100644 --- a/git-workon-review/src/error.rs +++ b/git-workon-review/src/error.rs @@ -11,4 +11,29 @@ pub enum ReviewError { #[error(transparent)] #[diagnostic(code(workon::review::git_error))] Git(#[from] git2::Error), + + /// Diff construction or acquisition failed + #[error(transparent)] + #[diagnostic(transparent)] + Diff(#[from] DiffError), +} + +/// Errors building a [`crate::model::DiffModel`] from git2 structures, or acquiring one for a +/// `workon::Changeset` (`git-workon-lib`). +#[derive(Error, Diagnostic, Debug)] +pub enum DiffError { + /// A git2 call failed while building or reading a diff/patch + #[error(transparent)] + #[diagnostic(code(workon::review::diff_git_error))] + Git(#[from] git2::Error), + + /// Diffing a changeset's resolved rev pair failed — a bad/garbage `Oid` never yields an + /// empty [`crate::model::DiffModel`], it yields this error. + #[error("failed to diff changeset '{name}'")] + #[diagnostic(code(workon::review::changeset_diff_failed))] + ChangesetDiffFailed { + name: String, + #[source] + source: git2::Error, + }, } diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index f76815a..ade2c64 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -1,11 +1,14 @@ //! Core library for `git-workon-review`, a TUI for reviewing changesets. //! -//! This library (lib target `workon_review`) will hold the review domain: -//! diff parsing, word-diff, line-precise staging, and changeset views. See -//! `docs/rfc/workon-review.md` in the workspace root for the full design. +//! This library (lib target `workon_review`) holds the review domain: diff parsing, +//! word-diff, line-precise staging, and changeset views. See `docs/rfc/workon-review.md` in +//! the workspace root for the full design. //! //! ## Status //! -//! M0 scaffolding only — no review logic exists yet. +//! M2: the diff model ([`model`]) and its acquisition from [`workon::Changeset`]s +//! ([`acquire`]) exist; synthesis, staging, and refresh land in later M2 changesets. +pub mod acquire; pub mod error; +pub mod model; diff --git a/git-workon-review/src/model.rs b/git-workon-review/src/model.rs new file mode 100644 index 0000000..c707193 --- /dev/null +++ b/git-workon-review/src/model.rs @@ -0,0 +1,236 @@ +//! The diff model: [`DiffModel`]/[`FileChange`]/[`Hunk`]/[`HunkLine`] built directly from +//! git2 [`git2::Diff`]/[`git2::Patch`] structures. +//! +//! Per the M2 design decision, this is NOT a unified-diff-text parser: it walks git2's own +//! line callbacks (content bytes + origin chars, including the EOFNL origins `=`/`>`/`<`) so +//! the model can byte-exactly re-render the patches it read ([`Hunk::to_diff_bytes`]). +//! +//! ## EOFNL characterization (see `tests/diff_model.rs`) +//! +//! git2 never emits a separate pseudo-line for a missing trailing newline. Instead, when a +//! real line (context/addition/deletion) is the last line of a file lacking a trailing +//! newline, git2 emits that line's content WITHOUT the newline, immediately followed by a +//! marker line whose origin is one of: +//! +//! - `ContextEOFNL` (`=`) — the preceding CONTEXT line has no trailing newline. +//! - `AddEOFNL` (`>`) — the preceding DELETION line's old-side content has no trailing +//! newline (despite the name, this marks the OLD/`-` side, not the `+` side — verified +//! empirically, do not trust the enum name). +//! - `DeleteEOFNL` (`<`) — the preceding ADDITION line's new-side content has no trailing +//! newline (again, the name is the mirror of what you'd expect). +//! +//! The marker's own content is `"\n\\ No newline at end of file\n"` — the leading `\n` +//! supplies the newline the preceding line omitted. [`DiffModel::from_git2`] does not push a +//! separate line for these markers; it sets [`HunkLine::missing_newline`] on the +//! most-recently-pushed line instead, matching the sketch in the plan. + +use crate::error::DiffError; + +/// What kind of line a [`HunkLine`] is, independent of which side of the patch it came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineKind { + Context, + Addition, + Deletion, +} + +/// One line of hunk content, carrying EXACT bytes (no trailing `\n` normalization). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HunkLine { + pub kind: LineKind, + /// Exact bytes as git2 reported them, including the trailing `\n` when present. When + /// [`missing_newline`](Self::missing_newline) is set, these bytes do NOT end in `\n` — + /// the file's last line genuinely has none. + pub content: Vec, + pub old_lnum: Option, + pub new_lnum: Option, + /// Set from the EOFNL origin markers (`=`/`>`/`<`) that git2 emits immediately after this + /// line when it is the last line of a file with no trailing newline. No pseudo-line is + /// ever pushed for the marker itself — see the module docs. + pub missing_newline: bool, +} + +/// One `@@ ... @@` hunk, re-renderable byte-for-byte via [`Hunk::to_diff_bytes`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hunk { + pub old_start: u32, + pub old_count: u32, + pub new_start: u32, + pub new_count: u32, + /// Verbatim `@@ -old_start,old_count +new_start,new_count @@ ...` bytes from git2, + /// including trailing `\n` — keeps any function-context suffix git2 attaches. + pub header: Vec, + pub lines: Vec, +} + +impl Hunk { + /// Byte-exact re-render of this hunk: header followed by each line's origin-prefixed + /// content, splicing in the git-canonical `\ No newline at end of file` marker (in the + /// exact byte sequence git2 uses: a bare `\n` continuing the truncated line, then the + /// marker text) wherever [`HunkLine::missing_newline`] is set. + /// + /// Fidelity is pinned against `git2::Diff::print(DiffFormat::Patch)` output in + /// `tests/diff_model.rs`. + pub fn to_diff_bytes(&self) -> Vec { + let mut out = self.header.clone(); + for line in &self.lines { + let prefix: u8 = match line.kind { + LineKind::Context => b' ', + LineKind::Addition => b'+', + LineKind::Deletion => b'-', + }; + out.push(prefix); + out.extend_from_slice(&line.content); + if line.missing_newline { + out.extend_from_slice(b"\n\\ No newline at end of file\n"); + } + } + out + } +} + +/// What kind of change a [`FileChange`] represents. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileStatus { + Modified, + Added, + Deleted, + Renamed, + Copied, + Untracked, + Unmerged, +} + +impl From for FileStatus { + fn from(delta: git2::Delta) -> Self { + match delta { + git2::Delta::Added => FileStatus::Added, + git2::Delta::Deleted => FileStatus::Deleted, + git2::Delta::Renamed => FileStatus::Renamed, + git2::Delta::Copied => FileStatus::Copied, + git2::Delta::Untracked => FileStatus::Untracked, + git2::Delta::Conflicted => FileStatus::Unmerged, + // Modified, Unmodified, Ignored, Typechange, Unreadable: none of these are + // distinct routing targets in the M2 model; fall back to Modified, the ordinary + // hunk-diffable case. + _ => FileStatus::Modified, + } + } +} + +/// One changed file, with its hunks (empty for binary files — see [`FileChange::is_binary`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileChange { + pub path: String, + /// The pre-change path for [`FileStatus::Renamed`]/[`FileStatus::Copied`]; `None` + /// otherwise. + pub old_path: Option, + pub status: FileStatus, + pub is_binary: bool, + pub hunks: Vec, +} + +/// A diff, built from git2 structures — see the module docs for the EOFNL characterization +/// and [`Hunk::to_diff_bytes`] for the byte-fidelity contract. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffModel { + pub files: Vec, +} + +impl DiffModel { + /// Build a [`DiffModel`] from a git2 [`git2::Diff`], iterating deltas and hunk-diffing + /// each non-binary one via [`git2::Patch::from_diff`]. + /// + /// Untracked deltas carry zero OIDs in git2 — this never reads blob ids off a delta; + /// content always arrives via the patch line callbacks. + pub fn from_git2(diff: &git2::Diff<'_>) -> Result { + let mut files = Vec::with_capacity(diff.deltas().len()); + for i in 0..diff.deltas().len() { + let delta = diff + .get_delta(i) + .expect("index within diff.deltas().len() is always valid"); + let status = FileStatus::from(delta.status()); + + let old_path = delta.old_file().path().map(path_to_string); + let new_path = delta.new_file().path().map(path_to_string); + let path = match status { + FileStatus::Deleted => old_path.clone(), + _ => new_path.or_else(|| old_path.clone()), + } + .unwrap_or_default(); + let old_path = match status { + FileStatus::Renamed | FileStatus::Copied => old_path, + _ => None, + }; + + // The BINARY flag on the delta fetched via `diff.get_delta` is not yet + // populated — libgit2 only runs the binary content check while computing the + // patch. Build the patch unconditionally and re-check its delta's flags. + let mut is_binary = delta.flags().contains(git2::DiffFlags::BINARY); + let mut hunks = Vec::new(); + if let Some(patch) = git2::Patch::from_diff(diff, i)? { + is_binary = is_binary || patch.delta().flags().contains(git2::DiffFlags::BINARY); + if !is_binary { + hunks = hunks_from_patch(&patch)?; + } + } + + files.push(FileChange { + path, + old_path, + status, + is_binary, + hunks, + }); + } + Ok(DiffModel { files }) + } +} + +fn path_to_string(path: &std::path::Path) -> String { + path.to_string_lossy().into_owned() +} + +fn hunks_from_patch(patch: &git2::Patch<'_>) -> Result, DiffError> { + let mut hunks = Vec::with_capacity(patch.num_hunks()); + for h in 0..patch.num_hunks() { + let (raw_hunk, line_count) = patch.hunk(h)?; + let mut lines: Vec = Vec::with_capacity(line_count); + for l in 0..line_count { + let line = patch.line_in_hunk(h, l)?; + let kind = match line.origin_value() { + git2::DiffLineType::Context => LineKind::Context, + git2::DiffLineType::Addition => LineKind::Addition, + git2::DiffLineType::Deletion => LineKind::Deletion, + git2::DiffLineType::ContextEOFNL + | git2::DiffLineType::AddEOFNL + | git2::DiffLineType::DeleteEOFNL => { + // No pseudo-line: mark the most recently pushed real line instead (see + // module docs for the EOFNL characterization). + if let Some(last) = lines.last_mut() { + last.missing_newline = true; + } + continue; + } + // FileHeader/HunkHeader/Binary never appear via `line_in_hunk`. + _ => continue, + }; + lines.push(HunkLine { + kind, + content: line.content().to_vec(), + old_lnum: line.old_lineno(), + new_lnum: line.new_lineno(), + missing_newline: false, + }); + } + hunks.push(Hunk { + old_start: raw_hunk.old_start(), + old_count: raw_hunk.old_lines(), + new_start: raw_hunk.new_start(), + new_count: raw_hunk.new_lines(), + header: raw_hunk.header().to_vec(), + lines, + }); + } + Ok(hunks) +} diff --git a/git-workon-review/tests/diff_model.rs b/git-workon-review/tests/diff_model.rs new file mode 100644 index 0000000..fa77fa2 --- /dev/null +++ b/git-workon-review/tests/diff_model.rs @@ -0,0 +1,409 @@ +//! Model-shape and byte-fidelity tests for `workon_review::model`/`workon_review::acquire`. +//! +//! The EOFNL characterization test pins what git2 0.21 actually emits for a no-trailing-newline +//! file (plan risk #2) — this is normative for CS2/CS3's patch synthesis, not just a sanity +//! check. Fixtures used for byte assertions pin `core.autocrlf=false` so bytes are +//! platform-stable (plan risk #6). + +use git2::{BranchType, Oid, Repository}; +use git_workon_fixture::prelude::*; +use workon::{assemble_changesets, Changeset, ChangesetSource, StackModel}; +use workon_review::acquire::{diff_changeset, diff_committed, diff_uncommitted, ChangesetDiff}; +use workon_review::error::DiffError; +use workon_review::model::{FileStatus, LineKind}; + +/// Commit `path`/`content` as a child of `parent`, without moving any branch ref — callers +/// reassign a branch to the returned `Oid` via `Fixture::update_branch` themselves. Used where +/// the `deleted_file`/`unstaged_file` baseline builders don't fit (advancing one Graphite +/// branch's tip independent of `main`'s). +fn commit_onto(repo: &Repository, parent: &git2::Commit, path: &str, content: &str) -> Oid { + let mut treebuilder = repo.treebuilder(Some(&parent.tree().unwrap())).unwrap(); + let blob_oid = repo.blob(content.as_bytes()).unwrap(); + treebuilder + .insert(path, blob_oid, git2::FileMode::Blob.into()) + .unwrap(); + let tree_oid = treebuilder.write().unwrap(); + let tree = repo.find_tree(tree_oid).unwrap(); + let sig = repo.signature().unwrap(); + repo.commit(None, &sig, &sig, "test commit", &tree, &[parent]) + .unwrap() +} + +// ── model shape ────────────────────────────────────────────────────────────── + +#[test] +fn staged_file_is_added_with_no_hunks_diff_needed() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("new.txt", "hello\n") + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + assert_eq!(diffs.staged.files.len(), 1); + let file = &diffs.staged.files[0]; + assert_eq!(file.path, "new.txt"); + assert_eq!(file.status, FileStatus::Added); + assert!(!file.is_binary); + assert_eq!(diffs.unstaged.files.len(), 0); + + Ok(()) +} + +#[test] +fn unstaged_file_is_modified_with_one_hunk() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "tracked.txt", + "line1\nline2\nline3\n", + "line1\nCHANGED\nline3\n", + ) + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + assert_eq!(diffs.unstaged.files.len(), 1); + let file = &diffs.unstaged.files[0]; + assert_eq!(file.path, "tracked.txt"); + assert_eq!(file.status, FileStatus::Modified); + assert_eq!(file.hunks.len(), 1); + assert_eq!(diffs.staged.files.len(), 0); + + Ok(()) +} + +#[test] +fn untracked_file_has_full_content_as_addition() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "hello\nworld\n") + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + assert_eq!(diffs.unstaged.files.len(), 1); + let file = &diffs.unstaged.files[0]; + assert_eq!(file.path, "new.txt"); + assert_eq!(file.status, FileStatus::Untracked); + assert_eq!(file.hunks.len(), 1); + assert!(file.hunks[0] + .lines + .iter() + .all(|l| l.kind == LineKind::Addition)); + + Ok(()) +} + +#[test] +fn deleted_file_is_deleted_status() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .deleted_file("gone.txt", "content\n") + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + assert_eq!(diffs.unstaged.files.len(), 1); + let file = &diffs.unstaged.files[0]; + assert_eq!(file.path, "gone.txt"); + assert_eq!(file.status, FileStatus::Deleted); + + Ok(()) +} + +#[test] +fn renamed_file_carries_old_path() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build()?; + let repo = fixture.repo()?; + + let base = repo.head()?.peel_to_commit()?; + let base_oid = commit_onto(repo, &base, "old.txt", "line1\nline2\nline3\n"); + let base_commit = repo.find_commit(base_oid)?; + + // Rename: drop old.txt, add new.txt with the same (similar-enough) content. + let mut treebuilder = repo.treebuilder(Some(&base_commit.tree()?))?; + treebuilder.remove("old.txt")?; + let blob_oid = repo.blob(b"line1\nline2\nline3\n")?; + treebuilder.insert("new.txt", blob_oid, git2::FileMode::Blob.into())?; + let tree_oid = treebuilder.write()?; + let tree = repo.find_tree(tree_oid)?; + let sig = repo.signature()?; + let head_oid = repo.commit(None, &sig, &sig, "rename", &tree, &[&base_commit])?; + + let model = diff_committed(repo, base_oid, head_oid)?; + assert_eq!(model.files.len(), 1); + let file = &model.files[0]; + assert_eq!(file.status, FileStatus::Renamed); + assert_eq!(file.path, "new.txt"); + assert_eq!(file.old_path.as_deref(), Some("old.txt")); + + Ok(()) +} + +#[test] +fn binary_file_has_no_hunks() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .bare(true) + .worktree("main") + .build()?; + + let base_oid = fixture + .commit("main") + .file_bytes("bin.dat", vec![0u8, 1, 2, 3, b'a', 0u8]) + .create("add binary")?; + let head_oid = fixture + .commit("main") + .file_bytes("bin.dat", vec![0u8, 9, 9, 9, b'z', 0u8]) + .create("change binary")?; + + let repo = fixture.repo()?; + let model = diff_committed(repo, base_oid, head_oid)?; + assert_eq!(model.files.len(), 1); + let file = &model.files[0]; + assert_eq!(file.path, "bin.dat"); + assert!(file.is_binary); + assert!(file.hunks.is_empty()); + + Ok(()) +} + +// ── EOFNL characterization (plan risk #2 — normative for CS2/CS3) ──────────── + +/// Pins git2 0.21's actual EOFNL behavior for a file with no trailing newline whose middle +/// line changes: git2 emits the trailing context line WITHOUT its newline, immediately +/// followed by a `ContextEOFNL` ('=') marker line whose content is exactly +/// `"\n\\ No newline at end of file\n"`. No pseudo-line lands in the model — the marker sets +/// `missing_newline` on the preceding (already-pushed) context [`HunkLine`]. +#[test] +fn eofnl_context_marker_sets_missing_newline_on_preceding_line( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "f.txt", + "line1\nline2\nline3", + "line1\nline2-changed\nline3", + ) + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + assert_eq!(file.hunks.len(), 1); + let lines = &file.hunks[0].lines; + + // Exactly 4 real lines: no pseudo-line for the EOFNL marker. + assert_eq!(lines.len(), 4); + + assert_eq!(lines[0].kind, LineKind::Context); + assert_eq!(lines[0].content, b"line1\n"); + assert!(!lines[0].missing_newline); + + assert_eq!(lines[1].kind, LineKind::Deletion); + assert_eq!(lines[1].content, b"line2\n"); + assert!(!lines[1].missing_newline); + + assert_eq!(lines[2].kind, LineKind::Addition); + assert_eq!(lines[2].content, b"line2-changed\n"); + assert!(!lines[2].missing_newline); + + // The trailing context line: git2 hands back content WITHOUT the newline, and the + // ContextEOFNL marker (content "\n\\ No newline at end of file\n") sets the flag instead + // of appearing as its own line. + assert_eq!(lines[3].kind, LineKind::Context); + assert_eq!(lines[3].content, b"line3"); + assert!(lines[3].missing_newline); + + Ok(()) +} + +/// Mirror of the context case, but the DELETION side (old file) lacks the trailing newline — +/// git2 emits the marker as `AddEOFNL` ('>'), despite the name marking the OLD/`-` side, not +/// the `+` side. Verified empirically; do not trust the enum name. +#[test] +fn eofnl_marker_on_deletion_side_when_old_file_lacks_trailing_newline( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3", "line1\nline2\nline3\n") + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + assert_eq!(file.hunks.len(), 1); + let lines = &file.hunks[0].lines; + + // context, context, deletion(no nl, flagged), addition(with nl) — 4 real lines. + assert_eq!(lines.len(), 4); + assert_eq!(lines[2].kind, LineKind::Deletion); + assert_eq!(lines[2].content, b"line3"); + assert!(lines[2].missing_newline); + assert_eq!(lines[3].kind, LineKind::Addition); + assert_eq!(lines[3].content, b"line3\n"); + assert!(!lines[3].missing_newline); + + Ok(()) +} + +/// Mirror again: the ADDITION side (new file) lacks the trailing newline — git2 emits +/// `DeleteEOFNL` ('<'), again the mirror of what the name suggests. +#[test] +fn eofnl_marker_on_addition_side_when_new_file_lacks_trailing_newline( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nline2\nline3") + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let lines = &file.hunks[0].lines; + + assert_eq!(lines.len(), 4); + assert_eq!(lines[2].kind, LineKind::Deletion); + assert_eq!(lines[2].content, b"line3\n"); + assert!(!lines[2].missing_newline); + assert_eq!(lines[3].kind, LineKind::Addition); + assert_eq!(lines[3].content, b"line3"); + assert!(lines[3].missing_newline); + + Ok(()) +} + +// ── byte-fidelity ───────────────────────────────────────────────────────────── + +/// Render the hunk-body bytes (hunk header + lines, no file header) straight off +/// `Diff::print(DiffFormat::Patch)`, the same way real diff text is produced — the reference +/// [`Hunk::to_diff_bytes`] is pinned against. +fn print_hunk_bytes(diff: &git2::Diff<'_>) -> Vec { + let mut out = Vec::new(); + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + match line.origin_value() { + git2::DiffLineType::FileHeader => {} + git2::DiffLineType::HunkHeader + | git2::DiffLineType::ContextEOFNL + | git2::DiffLineType::AddEOFNL + | git2::DiffLineType::DeleteEOFNL => { + out.extend_from_slice(line.content()); + } + git2::DiffLineType::Context => { + out.push(b' '); + out.extend_from_slice(line.content()); + } + git2::DiffLineType::Addition => { + out.push(b'+'); + out.extend_from_slice(line.content()); + } + git2::DiffLineType::Deletion => { + out.push(b'-'); + out.extend_from_slice(line.content()); + } + git2::DiffLineType::Binary => {} + } + true + }) + .unwrap(); + out +} + +#[test] +fn hunk_to_diff_bytes_matches_diff_print() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "f.txt", + "line1\nline2\nline3\nline4\n", + "line1\nCHANGED\nline3\nline4", + ) + .build()?; + let repo = fixture.repo()?; + + let mut opts = git2::DiffOptions::new(); + opts.context_lines(3); + let diff = repo.diff_index_to_workdir(None, Some(&mut opts))?; + + let model = workon_review::model::DiffModel::from_git2(&diff)?; + assert_eq!(model.files.len(), 1); + assert_eq!(model.files[0].hunks.len(), 1); + + let expected = print_hunk_bytes(&diff); + let actual = model.files[0].hunks[0].to_diff_bytes(); + assert_eq!( + String::from_utf8_lossy(&actual), + String::from_utf8_lossy(&expected) + ); + assert_eq!(actual, expected); + + Ok(()) +} + +// ── diff_changeset over a real assemble_changesets result ───────────────────── + +#[test] +fn diff_changeset_over_real_graphite_stack() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .build()?; + let repo = fixture.repo()?; + + let main_tip = repo + .find_branch("main", BranchType::Local)? + .get() + .target() + .unwrap(); + let main_commit = repo.find_commit(main_tip)?; + // Advance "a" independently of "main" so base != head. + let a_head = commit_onto(repo, &main_commit, "feature.txt", "hello\n"); + fixture.update_branch("a", a_head)?; + + let changesets = assemble_changesets(repo, "a", StackModel::Graphite)?; + let a_cs = changesets + .iter() + .find(|c| c.name == "a") + .expect("assembled changeset for 'a'"); + + match diff_changeset(repo, a_cs)? { + ChangesetDiff::Committed(model) => { + assert_eq!(model.files.len(), 1); + assert_eq!(model.files[0].path, "feature.txt"); + assert_eq!(model.files[0].status, FileStatus::Added); + } + ChangesetDiff::Uncommitted(_) => panic!("expected a Committed diff for a Graphite node"), + } + + Ok(()) +} + +#[test] +fn diff_changeset_with_bad_base_oid_fails_never_empty() -> Result<(), Box> { + let fixture = FixtureBuilder::new().build()?; + let repo = fixture.repo()?; + let head = repo.head()?.peel_to_commit()?.id(); + + let cs = Changeset { + name: "bogus".to_string(), + source: ChangesetSource::Committed { + base: Oid::ZERO_SHA1, + head, + }, + title: None, + current: false, + needs_restack: false, + }; + + let err = diff_changeset(repo, &cs).expect_err("a garbage base Oid must error, not diff empty"); + match err { + DiffError::ChangesetDiffFailed { name, .. } => assert_eq!(name, "bogus"), + other => panic!("expected ChangesetDiffFailed, got {other:?}"), + } + + Ok(()) +} From ddad3f94a2050915ea7ef59f23c8db917326733b Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 00:21:58 -0400 Subject: [PATCH 006/203] feat(review): synthesize whole-hunk patches from the model --- git-workon-review/src/error.rs | 35 +++ git-workon-review/src/lib.rs | 6 +- git-workon-review/src/synthesis.rs | 447 +++++++++++++++++++++++++++++ 3 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 git-workon-review/src/synthesis.rs diff --git a/git-workon-review/src/error.rs b/git-workon-review/src/error.rs index a032f19..565d235 100644 --- a/git-workon-review/src/error.rs +++ b/git-workon-review/src/error.rs @@ -1,6 +1,8 @@ use miette::Diagnostic; use thiserror::Error; +use crate::model::FileStatus; + /// Result type alias using ReviewError pub type Result = std::result::Result; @@ -16,6 +18,11 @@ pub enum ReviewError { #[error(transparent)] #[diagnostic(transparent)] Diff(#[from] DiffError), + + /// Patch synthesis from the diff model failed + #[error(transparent)] + #[diagnostic(transparent)] + Synthesis(#[from] SynthesisError), } /// Errors building a [`crate::model::DiffModel`] from git2 structures, or acquiring one for a @@ -37,3 +44,31 @@ pub enum DiffError { source: git2::Error, }, } + +/// Errors synthesizing a [`crate::synthesis::PatchText`] from a [`crate::model::FileChange`]. +#[derive(Error, Diagnostic, Debug)] +pub enum SynthesisError { + /// No lines were kept for the patch — nothing to apply. + #[error("no lines selected to synthesize a patch for '{path}' hunk {hunk}")] + #[diagnostic(code(workon::review::empty_selection))] + EmptySelection { path: String, hunk: usize }, + + /// `hunk_idx` didn't name a hunk on the file. + #[error("hunk index {index} out of range for '{path}'")] + #[diagnostic(code(workon::review::hunk_out_of_range))] + HunkOutOfRange { path: String, index: usize }, + + /// The file's status can't be expressed as a hunk patch (trap 3: whole-file ops route + /// around synthesis entirely; this is what a caller sees if it reaches synthesis anyway). + #[error("line-precise selection is not supported for '{path}' ({status:?})")] + #[diagnostic( + code(workon::review::line_selection_unsupported), + help("stage/unstage/discard the whole file instead") + )] + LineSelectionUnsupported { path: String, status: FileStatus }, + + /// The file is binary — there are no hunks to synthesize a patch from. + #[error("'{path}' is a binary file and cannot be patched by hunk")] + #[diagnostic(code(workon::review::binary_file))] + BinaryFile { path: String }, +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index ade2c64..f79420d 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -6,9 +6,11 @@ //! //! ## Status //! -//! M2: the diff model ([`model`]) and its acquisition from [`workon::Changeset`]s -//! ([`acquire`]) exist; synthesis, staging, and refresh land in later M2 changesets. +//! M2: the diff model ([`model`]), its acquisition from [`workon::Changeset`]s +//! ([`acquire`]), and whole-hunk patch synthesis ([`synthesis`]) exist; the apply chokepoint, +//! line-precise synthesis, file ops, staging, and refresh land in later M2 changesets. pub mod acquire; pub mod error; pub mod model; +pub mod synthesis; diff --git a/git-workon-review/src/synthesis.rs b/git-workon-review/src/synthesis.rs new file mode 100644 index 0000000..8f3eaa8 --- /dev/null +++ b/git-workon-review/src/synthesis.rs @@ -0,0 +1,447 @@ +//! Synthesizing invertible patch text from a [`crate::model::DiffModel`]. +//! +//! git2's write side takes bytes ([`git2::Diff::from_buffer`]), and the `git apply` CLI takes +//! text on stdin — but libgit2's `Repository::apply` has NO reverse flag (plan risk #1). A +//! "reverse apply" is therefore always: synthesize the forward patch, then +//! [`PatchText::invert`] it before handing it to an applier. [`PatchText`] stays structured +//! (not opaque bytes) so that inversion is a pure, testable transform instead of a text +//! rewrite. +//! +//! This module only synthesizes WHOLE hunks (`[whole_hunk_patch]`). Line-precise synthesis +//! (traps 1-2: direction-dependent drop rules, the EOFNL splice) lands in CS3 +//! (`partial_hunk_patch`). + +use crate::error::SynthesisError; +use crate::model::{FileChange, FileStatus, LineKind}; + +/// Which side of a patch is the "before" image — the direction-dependent drop rules (trap 1) +/// key off this. Whole-hunk patches (this module) don't drop lines, so `PatchBase` is +/// currently only consumed by [`crate::apply::StageVerb::plan`]; line-precise synthesis (CS3) +/// is where it drives which lines get kept vs. converted to context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PatchBase { + Old, + New, +} + +/// One line of a synthesized patch — mirrors [`crate::model::HunkLine`] minus the line-number +/// bookkeeping a patch doesn't need to render. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PatchLine { + pub kind: LineKind, + pub content: Vec, + pub missing_newline: bool, +} + +/// One `@@ ... @@` hunk of a [`PatchText`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PatchHunk { + pub old_start: u32, + pub old_count: u32, + pub new_start: u32, + pub new_count: u32, + /// Verbatim `@@ -old_start,old_count +new_start,new_count @@ ...` bytes (including + /// trailing `\n`) for a freshly synthesized (non-inverted) hunk — reused as-is from + /// [`crate::model::Hunk::header`] so any function-context suffix git2 attached survives. + /// [`PatchHunk`] rebuilds this field with swapped numbers (preserving the suffix) when + /// inverted; see [`PatchText::invert`]. + pub header: Vec, + pub lines: Vec, +} + +impl PatchHunk { + /// Render this hunk's bytes: header, then each line's origin-prefixed content, splicing + /// in the `\ No newline at end of file` marker wherever [`PatchLine::missing_newline`] is + /// set — byte-identical algorithm to [`crate::model::Hunk::to_diff_bytes`], since + /// [`whole_hunk_patch`] copies a model hunk's lines verbatim. + fn to_bytes(&self) -> Vec { + let mut out = self.header.clone(); + for line in &self.lines { + let prefix: u8 = match line.kind { + LineKind::Context => b' ', + LineKind::Addition => b'+', + LineKind::Deletion => b'-', + }; + out.push(prefix); + out.extend_from_slice(&line.content); + if line.missing_newline { + out.extend_from_slice(b"\n\\ No newline at end of file\n"); + } + } + out + } + + /// Swap old/new starts+counts, flip Addition<->Deletion (Context stays), and rebuild the + /// header text around the swapped numbers while preserving whatever trailing bytes + /// followed the second `@@` marker (a function-context suffix, or just `\n`). + fn invert(&self) -> PatchHunk { + let suffix = header_suffix(&self.header); + let header = format!( + "@@ -{},{} +{},{} @@", + self.new_start, self.new_count, self.old_start, self.old_count + ) + .into_bytes(); + let mut header = header; + header.extend_from_slice(&suffix); + + let lines = self + .lines + .iter() + .map(|line| PatchLine { + kind: match line.kind { + LineKind::Addition => LineKind::Deletion, + LineKind::Deletion => LineKind::Addition, + LineKind::Context => LineKind::Context, + }, + content: line.content.clone(), + missing_newline: line.missing_newline, + }) + .collect(); + + PatchHunk { + old_start: self.new_start, + old_count: self.new_count, + new_start: self.old_start, + new_count: self.old_count, + header, + lines, + } + } +} + +/// Everything after the second `@@` in a hunk header, e.g. `" fn foo() {\n"` or just `"\n"`. +fn header_suffix(header: &[u8]) -> Vec { + let find = |haystack: &[u8], needle: &[u8]| { + haystack + .windows(needle.len()) + .position(|window| window == needle) + }; + if let Some(first) = find(header, b"@@") { + if let Some(second_rel) = find(&header[first + 2..], b"@@") { + let second = first + 2 + second_rel; + return header[second + 2..].to_vec(); + } + } + b"\n".to_vec() +} + +/// A structured, invertible patch — the render/parse boundary between the model and the +/// appliers. `old_path`/`new_path` are `None` for a `/dev/null` side (whole-file +/// creation/deletion); [`whole_hunk_patch`] always sets both, since it only synthesizes +/// Modified/Renamed files. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PatchText { + pub old_path: Option, + pub new_path: Option, + pub hunks: Vec, +} + +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). + /// + /// The `index 0000000..0000000 100644` line is a placeholder — this crate never reads + /// blob OIDs off the model (untracked deltas don't have them either), and `git apply` + /// ignores it. It exists because `git2::Diff::from_buffer` parses stricter than `git + /// apply` and rejects a bare 3-line header (plan risk #4). + pub fn to_bytes(&self) -> Vec { + let mut out = Vec::new(); + let diff_git_old = self + .old_path + .as_deref() + .or(self.new_path.as_deref()) + .unwrap_or(""); + let diff_git_new = self + .new_path + .as_deref() + .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(b"index 0000000..0000000 100644\n"); + let old_label = match &self.old_path { + Some(p) => format!("a/{p}"), + None => "/dev/null".to_string(), + }; + let new_label = match &self.new_path { + Some(p) => format!("b/{p}"), + None => "/dev/null".to_string(), + }; + out.extend_from_slice(format!("--- {old_label}\n").as_bytes()); + out.extend_from_slice(format!("+++ {new_label}\n").as_bytes()); + for hunk in &self.hunks { + out.extend_from_slice(&hunk.to_bytes()); + } + out + } + + /// Pure transform: swap old/new paths and invert every hunk (trap 1's Old/New base swap, + /// applied wholesale). Needed because `Repository::apply` has no reverse flag — a + /// "reverse apply" is `invert()` then a forward apply. `invert(invert(p)) == p` (tested). + pub fn invert(&self) -> PatchText { + PatchText { + old_path: self.new_path.clone(), + new_path: self.old_path.clone(), + hunks: self.hunks.iter().map(PatchHunk::invert).collect(), + } + } +} + +/// Synthesize a patch for the WHOLE of `file`'s hunk at `hunk_idx` — no line selection, so the +/// direction-dependent drop rules (trap 1) don't apply; the hunk's lines are copied verbatim. +/// +/// Refuses: +/// - binary files ([`SynthesisError::BinaryFile`]) — no hunks exist to synthesize from. +/// - `hunk_idx` out of range ([`SynthesisError::HunkOutOfRange`]). +/// - 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 +/// `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`). +pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result { + if file.is_binary { + return Err(SynthesisError::BinaryFile { + path: file.path.clone(), + }); + } + match file.status { + FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied => {} + other => { + return Err(SynthesisError::LineSelectionUnsupported { + path: file.path.clone(), + status: other, + }) + } + } + let hunk = file + .hunks + .get(hunk_idx) + .ok_or_else(|| SynthesisError::HunkOutOfRange { + path: file.path.clone(), + index: hunk_idx, + })?; + + let old_path = file.old_path.clone().unwrap_or_else(|| file.path.clone()); + let new_path = file.path.clone(); + let lines = hunk + .lines + .iter() + .map(|line| PatchLine { + kind: line.kind, + content: line.content.clone(), + missing_newline: line.missing_newline, + }) + .collect(); + + Ok(PatchText { + old_path: Some(old_path), + new_path: Some(new_path), + hunks: vec![PatchHunk { + old_start: hunk.old_start, + old_count: hunk.old_count, + new_start: hunk.new_start, + new_count: hunk.new_count, + header: hunk.header.clone(), + lines, + }], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Hunk, HunkLine}; + + fn modified_file(hunk: Hunk) -> FileChange { + FileChange { + path: "f.txt".to_string(), + old_path: None, + status: FileStatus::Modified, + is_binary: false, + hunks: vec![hunk], + } + } + + fn simple_hunk() -> Hunk { + Hunk { + old_start: 1, + old_count: 3, + new_start: 1, + new_count: 3, + header: b"@@ -1,3 +1,3 @@\n".to_vec(), + lines: vec![ + HunkLine { + kind: LineKind::Context, + content: b"line1\n".to_vec(), + old_lnum: Some(1), + new_lnum: Some(1), + missing_newline: false, + }, + HunkLine { + kind: LineKind::Deletion, + content: b"line2\n".to_vec(), + old_lnum: Some(2), + new_lnum: None, + missing_newline: false, + }, + HunkLine { + kind: LineKind::Addition, + content: b"CHANGED\n".to_vec(), + old_lnum: None, + new_lnum: Some(2), + missing_newline: false, + }, + HunkLine { + kind: LineKind::Context, + content: b"line3\n".to_vec(), + old_lnum: Some(3), + new_lnum: Some(3), + missing_newline: false, + }, + ], + } + } + + #[test] + fn whole_hunk_patch_renders_exact_bytes() { + let file = modified_file(simple_hunk()); + let patch = whole_hunk_patch(&file, 0).unwrap(); + + let expected = [ + "diff --git a/f.txt b/f.txt\n", + "index 0000000..0000000 100644\n", + "--- a/f.txt\n", + "+++ b/f.txt\n", + "@@ -1,3 +1,3 @@\n", + " line1\n", + "-line2\n", + "+CHANGED\n", + " line3\n", + ] + .concat() + .into_bytes(); + + assert_eq!(patch.to_bytes(), expected); + } + + #[test] + fn whole_hunk_render_body_matches_model_hunk_to_diff_bytes() { + let hunk = simple_hunk(); + let file = modified_file(hunk.clone()); + let patch = whole_hunk_patch(&file, 0).unwrap(); + + // Strip the file header (4 lines: diff --git/index/---/+++) to compare just the hunk + // body against the model's own byte-fidelity contract. + let rendered = patch.to_bytes(); + let body_start = rendered + .windows(2) + .position(|w| w == b"@@") + .expect("hunk header present"); + let body = &rendered[body_start..]; + + assert_eq!(body, hunk.to_diff_bytes().as_slice()); + } + + #[test] + fn invert_of_invert_is_identity() { + let file = modified_file(simple_hunk()); + let patch = whole_hunk_patch(&file, 0).unwrap(); + + assert_eq!(patch.invert().invert(), patch); + } + + #[test] + fn invert_swaps_paths_and_line_kinds() { + let file = modified_file(simple_hunk()); + let patch = whole_hunk_patch(&file, 0).unwrap(); + let inverted = patch.invert(); + + assert_eq!(inverted.old_path, patch.new_path); + assert_eq!(inverted.new_path, patch.old_path); + assert_eq!(inverted.hunks[0].old_start, patch.hunks[0].new_start); + assert_eq!(inverted.hunks[0].new_start, patch.hunks[0].old_start); + assert_eq!(inverted.hunks[0].lines[1].kind, LineKind::Addition); + assert_eq!(inverted.hunks[0].lines[2].kind, LineKind::Deletion); + // Content and missing_newline travel with the line, unchanged. + assert_eq!(inverted.hunks[0].lines[1].content, b"line2\n"); + } + + #[test] + fn invert_moves_missing_newline_marker_with_its_line() { + let mut hunk = simple_hunk(); + // The deletion (old side) has no trailing newline. + hunk.lines[1].content = b"line2".to_vec(); + hunk.lines[1].missing_newline = true; + let file = modified_file(hunk); + let patch = whole_hunk_patch(&file, 0).unwrap(); + + let inverted = patch.invert(); + // The deletion becomes an addition in the inverted patch, carrying the flag with it. + assert_eq!(inverted.hunks[0].lines[1].kind, LineKind::Addition); + assert!(inverted.hunks[0].lines[1].missing_newline); + assert_eq!(inverted.hunks[0].lines[1].content, b"line2"); + } + + #[test] + fn refuses_binary_file() { + let file = FileChange { + path: "bin.dat".to_string(), + old_path: None, + status: FileStatus::Modified, + is_binary: true, + hunks: vec![], + }; + assert!(matches!( + whole_hunk_patch(&file, 0), + Err(SynthesisError::BinaryFile { .. }) + )); + } + + #[test] + fn refuses_hunk_index_out_of_range() { + let file = modified_file(simple_hunk()); + assert!(matches!( + whole_hunk_patch(&file, 1), + Err(SynthesisError::HunkOutOfRange { .. }) + )); + } + + #[test] + fn refuses_statuses_a_hunk_patch_cannot_express() { + for status in [ + FileStatus::Added, + FileStatus::Deleted, + FileStatus::Untracked, + FileStatus::Unmerged, + ] { + let file = FileChange { + path: "f.txt".to_string(), + old_path: None, + status, + is_binary: false, + hunks: vec![simple_hunk()], + }; + assert!( + matches!( + whole_hunk_patch(&file, 0), + Err(SynthesisError::LineSelectionUnsupported { .. }) + ), + "expected refusal for status {status:?}" + ); + } + } + + #[test] + fn renamed_file_uses_old_path_in_header() { + let mut file = modified_file(simple_hunk()); + file.status = FileStatus::Renamed; + file.old_path = Some("old.txt".to_string()); + let patch = whole_hunk_patch(&file, 0).unwrap(); + + assert_eq!(patch.old_path.as_deref(), Some("old.txt")); + assert_eq!(patch.new_path.as_deref(), Some("f.txt")); + } +} From 6836ba6e07e203f67fb1222e776dac353733eab9 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 00:26:23 -0400 Subject: [PATCH 007/203] feat(review): add patch applier over git2 and git CLI --- git-workon-review/src/apply.rs | 191 +++++++++++++++++++++++++++++++ git-workon-review/src/error.rs | 39 +++++++ git-workon-review/src/lib.rs | 6 +- git-workon-review/tests/apply.rs | 185 ++++++++++++++++++++++++++++++ 4 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 git-workon-review/src/apply.rs create mode 100644 git-workon-review/tests/apply.rs diff --git a/git-workon-review/src/apply.rs b/git-workon-review/src/apply.rs new file mode 100644 index 0000000..4959385 --- /dev/null +++ b/git-workon-review/src/apply.rs @@ -0,0 +1,191 @@ +//! Applying a [`PatchText`] to a repository's index or working tree — the one chokepoint +//! (per the M2 design decision) parameterizable over two backends: [`Git2Applier`] (libgit2's +//! `Repository::apply`) and [`CliApplier`] (`git apply` on stdin). The round-trip corpus (CS6) +//! runs every scenario against both; `CliApplier` is the oracle. +//! +//! ## The flag matrix (trap 1's chokepoint, prototype-verified) +//! +//! `git apply` takes ONLY `--cached`/`--reverse`, patch on stdin — never `--unidiff-zero`, +//! never `--3way`. [`StageVerb::plan`] encodes the same matrix for both backends: +//! +//! | verb | patch base | destination | direction | +//! |---------|------------|-------------|-----------| +//! | Stage | Old | Index | Forward | +//! | Unstage | New | Index | Reverse | +//! | Discard | New | Workdir | Reverse | +//! +//! ## `ApplyLocation::Index` preimage (plan risk #3) +//! +//! The index is not HEAD. A Stage patch must be synthesized from the unstaged model +//! (`index_to_workdir` — old side is the INDEX); an Unstage patch must be synthesized from the +//! staged model (`tree_to_index` — old side is HEAD). Feeding the wrong model's patch to +//! `ApplyLocation::Index` is the classic corruption source. Never `ApplyLocation::Both`. + +use std::io::Write; +use std::process::{Command, Stdio}; + +use git2::Repository; + +use crate::error::ApplyError; +use crate::synthesis::PatchText; + +/// Where a patch is applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyDestination { + Index, + Workdir, +} + +/// Whether the patch is applied as synthesized, or inverted first. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyDirection { + Forward, + Reverse, +} + +/// The three staging actions a review session performs. [`StageVerb::plan`] is the flag +/// matrix above, encoded once so `ops.rs` (CS4) and the applier tests share one source of +/// truth. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StageVerb { + Stage, + Unstage, + Discard, +} + +impl StageVerb { + /// `Stage`->(Old, Index, Forward); `Unstage`->(New, Index, Reverse); + /// `Discard`->(New, Workdir, Reverse). + pub fn plan( + self, + ) -> ( + crate::synthesis::PatchBase, + ApplyDestination, + ApplyDirection, + ) { + use crate::synthesis::PatchBase; + match self { + StageVerb::Stage => ( + PatchBase::Old, + ApplyDestination::Index, + ApplyDirection::Forward, + ), + StageVerb::Unstage => ( + PatchBase::New, + ApplyDestination::Index, + ApplyDirection::Reverse, + ), + StageVerb::Discard => ( + PatchBase::New, + ApplyDestination::Workdir, + ApplyDirection::Reverse, + ), + } + } +} + +/// A patch-application backend. [`Git2Applier`] and [`CliApplier`] both implement this over +/// the same [`PatchText`] — the round-trip corpus drives whichever `dyn Applier` it's handed. +pub trait Applier { + fn apply( + &self, + repo: &Repository, + patch: &PatchText, + dest: ApplyDestination, + dir: ApplyDirection, + ) -> Result<(), ApplyError>; +} + +/// Applies via libgit2's `Repository::apply`. `Reverse` is [`PatchText::invert`] followed by a +/// forward apply — `Repository::apply` itself has no reverse flag (plan risk #1). +pub struct Git2Applier; + +impl Applier for Git2Applier { + fn apply( + &self, + repo: &Repository, + patch: &PatchText, + dest: ApplyDestination, + dir: ApplyDirection, + ) -> Result<(), ApplyError> { + let bytes = match dir { + ApplyDirection::Forward => patch.to_bytes(), + ApplyDirection::Reverse => patch.invert().to_bytes(), + }; + let diff = git2::Diff::from_buffer(&bytes)?; + let location = match dest { + ApplyDestination::Index => git2::ApplyLocation::Index, + ApplyDestination::Workdir => git2::ApplyLocation::WorkDir, + }; + repo.apply(&diff, location, None)?; + Ok(()) + } +} + +/// Applies by spawning `git apply` with the patch on stdin, cwd set to the repository's +/// working directory. `Index` destination -> `--cached`; `Reverse` direction -> `--reverse`. +/// Never `--unidiff-zero`, never `--3way` (prototype chokepoint, trap 1). +pub struct CliApplier; + +impl Applier for CliApplier { + fn apply( + &self, + repo: &Repository, + patch: &PatchText, + dest: ApplyDestination, + dir: ApplyDirection, + ) -> Result<(), ApplyError> { + let workdir = repo + .workdir() + .expect("CliApplier requires a repository with a working directory"); + + let mut args = vec!["apply".to_string()]; + if dest == ApplyDestination::Index { + args.push("--cached".to_string()); + } + if dir == ApplyDirection::Reverse { + args.push("--reverse".to_string()); + } + + let mut child = Command::new("git") + .args(&args) + .current_dir(workdir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(ApplyError::GitSpawn)?; + + child + .stdin + .as_mut() + .expect("stdin was piped") + .write_all(&patch.to_bytes()) + .map_err(ApplyError::GitSpawn)?; + + let output = child.wait_with_output().map_err(ApplyError::GitSpawn)?; + if !output.status.success() { + return Err(ApplyError::CliApplyFailed { + args, + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + Ok(()) + } +} + +/// Classify an [`ApplyError`] as index-lock contention (plan risk #8), spanning both backends: +/// git2's `ErrorCode::Locked`, or its `Index`/`Os` error classes with "lock" in the message; +/// the CLI backend via `"index.lock"` in `git apply`'s stderr. +pub fn is_lock_contention(err: &ApplyError) -> bool { + match err { + ApplyError::Git(e) => { + e.code() == git2::ErrorCode::Locked + || ((e.class() == git2::ErrorClass::Index || e.class() == git2::ErrorClass::Os) + && e.message().to_lowercase().contains("lock")) + } + ApplyError::IndexLocked { .. } => true, + ApplyError::CliApplyFailed { stderr, .. } => stderr.contains("index.lock"), + ApplyError::GitSpawn(_) | ApplyError::Io { .. } => false, + } +} diff --git a/git-workon-review/src/error.rs b/git-workon-review/src/error.rs index 565d235..9b1dee0 100644 --- a/git-workon-review/src/error.rs +++ b/git-workon-review/src/error.rs @@ -23,6 +23,11 @@ pub enum ReviewError { #[error(transparent)] #[diagnostic(transparent)] Synthesis(#[from] SynthesisError), + + /// Applying a synthesized patch failed + #[error(transparent)] + #[diagnostic(transparent)] + Apply(#[from] ApplyError), } /// Errors building a [`crate::model::DiffModel`] from git2 structures, or acquiring one for a @@ -72,3 +77,37 @@ pub enum SynthesisError { #[diagnostic(code(workon::review::binary_file))] BinaryFile { path: String }, } + +/// Errors applying a [`crate::synthesis::PatchText`] via a [`crate::apply::Applier`]. +#[derive(Error, Diagnostic, Debug)] +pub enum ApplyError { + /// A git2 call failed while applying a patch + #[error(transparent)] + #[diagnostic(code(workon::review::apply_git_error))] + Git(#[from] git2::Error), + + /// The index stayed locked across every retry (see `queue.rs`'s retry-once policy). + #[error("index locked after {attempts} attempt(s)")] + #[diagnostic(code(workon::review::index_locked))] + IndexLocked { attempts: u32 }, + + /// `git apply` exited non-zero. + #[error("git apply failed (args: {args:?}): {stderr}")] + #[diagnostic(code(workon::review::cli_apply_failed))] + CliApplyFailed { args: Vec, stderr: String }, + + /// Spawning or communicating with the `git` subprocess failed (not a nonzero exit — that's + /// [`ApplyError::CliApplyFailed`]). + #[error("failed to spawn or communicate with git")] + #[diagnostic(code(workon::review::git_spawn_failed))] + GitSpawn(#[source] std::io::Error), + + /// A whole-file operation's filesystem I/O failed (`file_ops.rs`, CS4). + #[error("file operation on '{path}' failed")] + #[diagnostic(code(workon::review::file_op_io))] + Io { + path: String, + #[source] + source: std::io::Error, + }, +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index f79420d..1a402b3 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -7,10 +7,12 @@ //! ## Status //! //! M2: the diff model ([`model`]), its acquisition from [`workon::Changeset`]s -//! ([`acquire`]), and whole-hunk patch synthesis ([`synthesis`]) exist; the apply chokepoint, -//! line-precise synthesis, file ops, staging, and refresh land in later M2 changesets. +//! ([`acquire`]), whole-hunk patch synthesis ([`synthesis`]), and the apply chokepoint +//! ([`apply`]) exist; line-precise synthesis, file ops, staging, and refresh land in later M2 +//! changesets. pub mod acquire; +pub mod apply; pub mod error; pub mod model; pub mod synthesis; diff --git a/git-workon-review/tests/apply.rs b/git-workon-review/tests/apply.rs new file mode 100644 index 0000000..91dba07 --- /dev/null +++ b/git-workon-review/tests/apply.rs @@ -0,0 +1,185 @@ +//! Whole-hunk apply round-trips, run against BOTH `Git2Applier` and `CliApplier` via +//! `for_each_applier` (plan trap 6: CLI is the oracle, git2 is re-verified against it). Each +//! test builds a FRESH fixture per applier — appliers mutate live repository state, so sharing +//! one fixture across both runs would let the second applier's assertions depend on the +//! first's side effects. +//! +//! Fixtures pin `core.autocrlf=false` so index/workdir byte assertions are platform-stable +//! (plan risk #6). + +use std::path::Path; + +use git_workon_fixture::prelude::*; +use workon_review::acquire::diff_uncommitted; +use workon_review::apply::{is_lock_contention, Applier, CliApplier, Git2Applier, StageVerb}; +use workon_review::error::ApplyError; +use workon_review::synthesis::whole_hunk_patch; + +/// Run `test` once per applier backend. Each invocation gets its own closure body so callers +/// build a fresh fixture inside `test` rather than sharing one across backends. +fn for_each_applier(mut test: impl FnMut(&dyn Applier)) { + test(&Git2Applier); + test(&CliApplier); +} + +#[test] +fn stage_whole_hunk_updates_index_and_leaves_workdir_untouched() { + for_each_applier(|applier| { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nCHANGED\nline3\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 patch = whole_hunk_patch(file, 0).expect("whole_hunk_patch"); + + let (_, dest, dir) = StageVerb::Stage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nCHANGED\nline3\n".to_vec(), + )); + // The Index-only apply must not touch the working tree, which still carries the + // original unstaged modification. + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"line1\nCHANGED\nline3\n".to_vec(), + )); + }); +} + +#[test] +fn unstage_whole_hunk_reverts_index_to_head_content() { + for_each_applier(|applier| { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nCHANGED\nline3\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + // Stage the modification directly (index := workdir content for this path) so the + // staged (tree_to_index) model sees the same hunk the unstaged model saw — the + // unstage patch's preimage must be the INDEX, per plan risk #3. + let mut index = repo.index().expect("index"); + index.add_path(Path::new("f.txt")).expect("add_path"); + index.write().expect("index write"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.staged.files[0]; + let patch = whole_hunk_patch(file, 0).expect("whole_hunk_patch"); + + let (_, dest, dir) = StageVerb::Unstage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nline2\nline3\n".to_vec(), + )); + }); +} + +#[test] +fn discard_whole_hunk_reverts_workdir_to_committed_content() { + for_each_applier(|applier| { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nCHANGED\nline3\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 patch = whole_hunk_patch(file, 0).expect("whole_hunk_patch"); + + let (_, dest, dir) = StageVerb::Discard.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"line1\nline2\nline3\n".to_vec(), + )); + }); +} + +/// First live proof that the marker rendering ([`workon_review::synthesis::PatchHunk`], +/// carrying `\ No newline at end of file`) applies cleanly through a real applier: stage a +/// hunk whose new side lacks the trailing newline, and assert the exact (newline-less) index +/// bytes. +#[test] +fn stage_whole_hunk_with_missing_trailing_newline_on_new_side() { + for_each_applier(|applier| { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nline2\nline3") + .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 patch = whole_hunk_patch(file, 0).expect("whole_hunk_patch"); + + let (_, dest, dir) = StageVerb::Stage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nline2\nline3".to_vec(), + )); + }); +} + +/// Plan risk #8: lock classification spans `ErrorCode::Locked` and class `Index`/`Os` with +/// "lock" in the message (git2), or `"index.lock"` in stderr (CLI). `Repository::apply` locks +/// the index only while writing it, so a pre-existing `index.lock` file may or may not trip +/// git2's own preflight — if it doesn't, this manufactures the error git2 would raise on real +/// contention and asserts the classifier handles it, documenting the observed behavior either +/// way rather than silently no-op'ing. +#[test] +fn index_lock_contention_is_classified() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nCHANGED\nline3\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 patch = whole_hunk_patch(file, 0).expect("whole_hunk_patch"); + + let lock_path = repo.path().join("index.lock"); + std::fs::write(&lock_path, b"").expect("create index.lock"); + + let (_, dest, dir) = StageVerb::Stage.plan(); + let result = Git2Applier.apply(repo, &patch, dest, dir); + + std::fs::remove_file(&lock_path).ok(); + + match result { + Err(err) => { + assert!( + is_lock_contention(&err), + "expected a lock-contention error, got {err:?}" + ); + } + Ok(()) => { + // Repository::apply didn't trip over the pre-existing lock file (it locks only + // while writing, and this apply may not have needed to touch the index lock at + // the moment it checked) — manufacture the error libgit2 raises on real + // contention and prove the classifier itself is correct. + let manufactured = ApplyError::Git(git2::Error::new( + git2::ErrorCode::Locked, + git2::ErrorClass::Index, + "failed to lock file for writing", + )); + assert!(is_lock_contention(&manufactured)); + } + } +} From eb248b4c209975efe413cec529f808b2b873f598 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 17:26:14 -0400 Subject: [PATCH 008/203] fix(review): preserve real file mode when synthesizing patches --- git-workon-review/src/model.rs | 12 ++++++ git-workon-review/src/synthesis.rs | 62 +++++++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/git-workon-review/src/model.rs b/git-workon-review/src/model.rs index c707193..701d502 100644 --- a/git-workon-review/src/model.rs +++ b/git-workon-review/src/model.rs @@ -127,6 +127,16 @@ pub struct FileChange { pub old_path: Option, pub status: FileStatus, pub is_binary: bool, + /// Raw octal file mode (e.g. `0o100644`, `0o100755`) of the pre-image, from + /// `delta.old_file().mode()`. Carried alongside [`Self::new_mode`] so + /// [`crate::synthesis::whole_hunk_patch`] can pick the right mode for the patch's + /// direction — and [`crate::synthesis::PatchText::invert`] can swap them — instead of + /// clobbering the index entry's mode with a hardcoded `100644` (a real divergence: staging + /// any hunk of an executable file via the git2 applier used to silently reset it). + pub old_mode: i32, + /// Raw octal file mode of the post-image, from `delta.new_file().mode()`. See + /// [`Self::old_mode`]. + pub new_mode: i32, pub hunks: Vec, } @@ -180,6 +190,8 @@ impl DiffModel { old_path, status, is_binary, + old_mode: i32::from(delta.old_file().mode()), + new_mode: i32::from(delta.new_file().mode()), hunks, }); } diff --git a/git-workon-review/src/synthesis.rs b/git-workon-review/src/synthesis.rs index 8f3eaa8..6adf35d 100644 --- a/git-workon-review/src/synthesis.rs +++ b/git-workon-review/src/synthesis.rs @@ -133,6 +133,13 @@ fn header_suffix(header: &[u8]) -> Vec { pub struct PatchText { pub old_path: Option, pub new_path: Option, + /// Raw octal mode of the pre-image (see [`FileChange::old_mode`]); swapped with + /// [`Self::new_mode`] by [`Self::invert`]. + pub old_mode: i32, + /// Raw octal mode of the post-image (see [`FileChange::new_mode`]) — this is the mode + /// written into the synthesized `index` line, since a forward patch's target state is the + /// post-image. + pub new_mode: i32, pub hunks: Vec, } @@ -141,10 +148,15 @@ impl PatchText { /// 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 100644` line is a placeholder — this crate never reads - /// blob OIDs off the model (untracked deltas don't have them either), and `git apply` - /// ignores it. It exists because `git2::Diff::from_buffer` parses stricter than `git - /// apply` and rejects a bare 3-line header (plan risk #4). + /// 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). pub fn to_bytes(&self) -> Vec { let mut out = Vec::new(); let diff_git_old = self @@ -158,7 +170,7 @@ 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(b"index 0000000..0000000 100644\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(), @@ -182,6 +194,8 @@ impl PatchText { PatchText { old_path: self.new_path.clone(), new_path: self.old_path.clone(), + old_mode: self.new_mode, + new_mode: self.old_mode, hunks: self.hunks.iter().map(PatchHunk::invert).collect(), } } @@ -239,6 +253,8 @@ pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result Date: Mon, 6 Jul 2026 00:38:04 -0400 Subject: [PATCH 009/203] feat(review): line-precise patch synthesis with direction rules --- git-workon-review/src/synthesis.rs | 390 +++++++++++++++++++++- git-workon-review/tests/line_synthesis.rs | 184 ++++++++++ 2 files changed, 568 insertions(+), 6 deletions(-) create mode 100644 git-workon-review/tests/line_synthesis.rs diff --git a/git-workon-review/src/synthesis.rs b/git-workon-review/src/synthesis.rs index 6adf35d..df78e84 100644 --- a/git-workon-review/src/synthesis.rs +++ b/git-workon-review/src/synthesis.rs @@ -7,17 +7,18 @@ //! (not opaque bytes) so that inversion is a pure, testable transform instead of a text //! rewrite. //! -//! This module only synthesizes WHOLE hunks (`[whole_hunk_patch]`). Line-precise synthesis -//! (traps 1-2: direction-dependent drop rules, the EOFNL splice) lands in CS3 -//! (`partial_hunk_patch`). +//! This module synthesizes WHOLE hunks (`[whole_hunk_patch]`) and line-precise selections +//! (`[partial_hunk_patch]`, traps 1-2: direction-dependent drop rules, the EOFNL splice). + +use std::collections::BTreeSet; use crate::error::SynthesisError; use crate::model::{FileChange, FileStatus, LineKind}; /// Which side of a patch is the "before" image — the direction-dependent drop rules (trap 1) -/// key off this. Whole-hunk patches (this module) don't drop lines, so `PatchBase` is -/// currently only consumed by [`crate::apply::StageVerb::plan`]; line-precise synthesis (CS3) -/// is where it drives which lines get kept vs. converted to context. +/// key off this. Whole-hunk patches don't drop lines, so `PatchBase` only affects +/// [`partial_hunk_patch`] (and is otherwise threaded through by [`crate::apply::StageVerb::plan`] +/// to pick which model a caller synthesizes from). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PatchBase { Old, @@ -266,6 +267,186 @@ pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result, + pub keep_dels: BTreeSet, +} + +/// Synthesize a patch for a LINE-PRECISE selection of `file`'s hunk at `hunk_idx` — the +/// direction-dependent drop rules (trap 1). +/// +/// Context lines are always emitted as context. For the rest, `base` decides what happens to a +/// line that ISN'T kept: +/// +/// - `base == Old` (forward apply — [`crate::apply::StageVerb::Stage`], staging into an index +/// that doesn't have the change yet): a dropped addition is OMITTED (the index shouldn't +/// gain it); a dropped deletion becomes CONTEXT (the index should keep what's still there). +/// - `base == New` (reverse apply — [`crate::apply::StageVerb::Unstage`]/[`Discard`], where the +/// apply target ALREADY has the change and reverse-applying undoes the kept lines): a dropped +/// addition becomes CONTEXT (it must stay in the target, so it has to match on reverse-apply +/// just like an untouched line does); a dropped deletion is OMITTED (it's already absent from +/// the target, so it must never be matched against). This is the mirror of the `Old` rules, +/// not merely a coincidence: whichever side already contains the "dropped" line is the side +/// the patch's context has to agree with, and `base` names that side. +/// +/// [`crate::apply::CliApplier`]/[`crate::apply::Git2Applier`] reverse-apply by adding +/// `--reverse` or by [`PatchText::invert`]ing before a forward apply — either way the patch +/// itself is always WRITTEN in forward orientation with the rules above; a `base == Old` +/// patch fed through a reverse apply is a different, incompatible set of drop rules and git +/// rejects it outright (see the tripwire test in `tests/line_synthesis.rs`). +/// +/// [`LineSelection`] entries that don't name an add/del line in this hunk are ignored (see +/// [`LineSelection`]'s docs). If, after ignoring those, no addition and no deletion ended up +/// kept, there is nothing to synthesize a patch for: [`SynthesisError::EmptySelection`]. +/// +/// 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. +/// +/// Same refusals as [`whole_hunk_patch`]: binary files ([`SynthesisError::BinaryFile`]), +/// unsupported statuses ([`SynthesisError::LineSelectionUnsupported`]), and an out-of-range +/// `hunk_idx` ([`SynthesisError::HunkOutOfRange`]). +/// +/// This function does not yet apply the trap-2 EOFNL splice (a dropped deletion converted to +/// context that carries [`crate::model::HunkLine::missing_newline`], followed by any kept +/// line, silently corrupts the blob under `git apply`) — see the follow-up commit. +pub fn partial_hunk_patch( + file: &FileChange, + hunk_idx: usize, + sel: &LineSelection, + base: PatchBase, +) -> Result { + if file.is_binary { + return Err(SynthesisError::BinaryFile { + path: file.path.clone(), + }); + } + match file.status { + FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied => {} + other => { + return Err(SynthesisError::LineSelectionUnsupported { + path: file.path.clone(), + status: other, + }) + } + } + let hunk = file + .hunks + .get(hunk_idx) + .ok_or_else(|| SynthesisError::HunkOutOfRange { + path: file.path.clone(), + index: hunk_idx, + })?; + + let mut kept_any = false; + let mut old_count = 0u32; + let mut new_count = 0u32; + let mut lines = Vec::with_capacity(hunk.lines.len()); + + for (idx, line) in hunk.lines.iter().enumerate() { + match line.kind { + LineKind::Context => { + old_count += 1; + new_count += 1; + lines.push(PatchLine { + kind: LineKind::Context, + content: line.content.clone(), + missing_newline: line.missing_newline, + }); + } + LineKind::Addition => { + if sel.keep_adds.contains(&idx) { + kept_any = true; + new_count += 1; + lines.push(PatchLine { + kind: LineKind::Addition, + content: line.content.clone(), + missing_newline: line.missing_newline, + }); + } else if base == PatchBase::New { + // Dropped addition, base=New: it must remain in the (already-changed) + // target, so it has to match as context on reverse-apply. + old_count += 1; + new_count += 1; + lines.push(PatchLine { + kind: LineKind::Context, + content: line.content.clone(), + missing_newline: line.missing_newline, + }); + } + // base=Old: dropped addition is omitted — the target doesn't have it yet and + // shouldn't gain it. + } + LineKind::Deletion => { + if sel.keep_dels.contains(&idx) { + kept_any = true; + old_count += 1; + lines.push(PatchLine { + kind: LineKind::Deletion, + content: line.content.clone(), + missing_newline: line.missing_newline, + }); + } else if base == PatchBase::Old { + // Dropped deletion, base=Old: it's still there in the target, so it has to + // match as context. + old_count += 1; + new_count += 1; + lines.push(PatchLine { + kind: LineKind::Context, + content: line.content.clone(), + missing_newline: line.missing_newline, + }); + } + // base=New: dropped deletion is omitted — it's already absent from the target + // and must never be matched against. + } + } + } + + if !kept_any { + return Err(SynthesisError::EmptySelection { + path: file.path.clone(), + hunk: hunk_idx, + }); + } + + let mut header = format!( + "@@ -{},{old_count} +{},{new_count} @@", + hunk.old_start, hunk.new_start + ) + .into_bytes(); + header.extend_from_slice(&header_suffix(&hunk.header)); + + let old_path = file.old_path.clone().unwrap_or_else(|| file.path.clone()); + let new_path = file.path.clone(); + + Ok(PatchText { + old_path: Some(old_path), + new_path: Some(new_path), + hunks: vec![PatchHunk { + old_start: hunk.old_start, + old_count, + new_start: hunk.new_start, + new_count, + header, + lines, + }], + }) +} + #[cfg(test)] mod tests { use super::*; @@ -496,4 +677,201 @@ mod tests { assert_eq!(patch.old_path.as_deref(), Some("old.txt")); assert_eq!(patch.new_path.as_deref(), Some("f.txt")); } + + /// Two separate changes ("old2"->"new2" and "old4"->"new4") in one hunk, with a context + /// line between them — the shape `partial_hunk_patch`'s direction rules are tested against: + /// keeping only the first change should drop the second one per `base`'s rule, not just + /// omit it uniformly. + /// + /// Line indices (into `hunk.lines`): 0 ctx "line1", 1 del "old2", 2 add "new2", 3 ctx + /// "line3", 4 del "old4", 5 add "new4", 6 ctx "line5". + fn two_change_hunk() -> Hunk { + let line = |kind, content: &str, old_lnum, new_lnum| HunkLine { + kind, + content: content.as_bytes().to_vec(), + old_lnum, + new_lnum, + missing_newline: false, + }; + Hunk { + old_start: 1, + old_count: 5, + new_start: 1, + new_count: 5, + header: b"@@ -1,5 +1,5 @@\n".to_vec(), + lines: vec![ + line(LineKind::Context, "line1\n", Some(1), Some(1)), + line(LineKind::Deletion, "old2\n", Some(2), None), + line(LineKind::Addition, "new2\n", None, Some(2)), + line(LineKind::Context, "line3\n", Some(3), Some(3)), + line(LineKind::Deletion, "old4\n", Some(4), None), + line(LineKind::Addition, "new4\n", None, Some(4)), + line(LineKind::Context, "line5\n", Some(5), Some(5)), + ], + } + } + + fn keep_first_change() -> LineSelection { + LineSelection { + keep_adds: BTreeSet::from([2]), + keep_dels: BTreeSet::from([1]), + } + } + + #[test] + fn partial_base_old_omits_dropped_add_and_contexts_dropped_del() { + let file = modified_file(two_change_hunk()); + let patch = partial_hunk_patch(&file, 0, &keep_first_change(), PatchBase::Old).unwrap(); + + let expected = [ + "diff --git a/f.txt b/f.txt\n", + "index 0000000..0000000 100644\n", + "--- a/f.txt\n", + "+++ b/f.txt\n", + "@@ -1,5 +1,5 @@\n", + " line1\n", + "-old2\n", + "+new2\n", + " line3\n", + " old4\n", + " line5\n", + ] + .concat() + .into_bytes(); + + assert_eq!(patch.to_bytes(), expected); + } + + #[test] + fn partial_base_new_omits_dropped_del_and_contexts_dropped_add() { + let file = modified_file(two_change_hunk()); + let patch = partial_hunk_patch(&file, 0, &keep_first_change(), PatchBase::New).unwrap(); + + let expected = [ + "diff --git a/f.txt b/f.txt\n", + "index 0000000..0000000 100644\n", + "--- a/f.txt\n", + "+++ b/f.txt\n", + "@@ -1,5 +1,5 @@\n", + " line1\n", + "-old2\n", + "+new2\n", + " line3\n", + " new4\n", + " line5\n", + ] + .concat() + .into_bytes(); + + assert_eq!(patch.to_bytes(), expected); + } + + #[test] + fn partial_recomputes_counts_when_kept_and_dropped_lines_differ() { + // Keep only the addition of the first change, dropping its deletion too (base=Old + // contexts the dropped deletion) — old_count grows relative to a hunk that dropped + // nothing, new_count reflects only the one kept addition among the two. + let file = modified_file(two_change_hunk()); + let sel = LineSelection { + keep_adds: BTreeSet::from([2]), + keep_dels: BTreeSet::new(), + }; + let patch = partial_hunk_patch(&file, 0, &sel, PatchBase::Old).unwrap(); + + // line1(ctx) old2(ctx, dropped del) new2(add, kept) line3(ctx) old4(ctx, dropped del) + // line5(ctx): old side never sees "new2" (5 lines), new side does (6 lines); new4 + // (dropped, unkept addition) is omitted from both. + assert_eq!(patch.hunks[0].old_count, 5); + assert_eq!(patch.hunks[0].new_count, 6); + assert_eq!(&patch.hunks[0].header[..], b"@@ -1,5 +1,6 @@\n".as_slice()); + } + + #[test] + fn partial_ignores_selection_indices_that_are_not_add_or_del() { + let file = modified_file(two_change_hunk()); + let mut sel = keep_first_change(); + // Index 0 is a context line; index 99 is out of range. Neither should change the + // rendered patch. + sel.keep_adds.insert(99); + sel.keep_dels.insert(0); + + let baseline = partial_hunk_patch(&file, 0, &keep_first_change(), PatchBase::Old).unwrap(); + let with_junk = partial_hunk_patch(&file, 0, &sel, PatchBase::Old).unwrap(); + + assert_eq!(with_junk.to_bytes(), baseline.to_bytes()); + } + + #[test] + fn partial_empty_selection_errors() { + let file = modified_file(two_change_hunk()); + let sel = LineSelection::default(); + + assert!(matches!( + partial_hunk_patch(&file, 0, &sel, PatchBase::Old), + Err(SynthesisError::EmptySelection { .. }) + )); + } + + #[test] + fn partial_selection_naming_only_context_lines_is_effectively_empty() { + let file = modified_file(two_change_hunk()); + let sel = LineSelection { + keep_adds: BTreeSet::from([0, 3, 6]), // all context indices, none are additions + keep_dels: BTreeSet::new(), + }; + + assert!(matches!( + partial_hunk_patch(&file, 0, &sel, PatchBase::Old), + Err(SynthesisError::EmptySelection { .. }) + )); + } + + #[test] + fn partial_refuses_binary_file() { + let file = FileChange { + path: "bin.dat".to_string(), + old_path: None, + status: FileStatus::Modified, + is_binary: true, + hunks: vec![], + }; + assert!(matches!( + partial_hunk_patch(&file, 0, &keep_first_change(), PatchBase::Old), + Err(SynthesisError::BinaryFile { .. }) + )); + } + + #[test] + fn partial_refuses_hunk_index_out_of_range() { + let file = modified_file(two_change_hunk()); + assert!(matches!( + partial_hunk_patch(&file, 1, &keep_first_change(), PatchBase::Old), + Err(SynthesisError::HunkOutOfRange { .. }) + )); + } + + #[test] + fn partial_refuses_statuses_a_hunk_patch_cannot_express() { + for status in [ + FileStatus::Added, + FileStatus::Deleted, + FileStatus::Untracked, + FileStatus::Unmerged, + ] { + let file = FileChange { + path: "f.txt".to_string(), + old_path: None, + status, + is_binary: false, + hunks: vec![two_change_hunk()], + }; + assert!( + matches!( + partial_hunk_patch(&file, 0, &keep_first_change(), PatchBase::Old), + Err(SynthesisError::LineSelectionUnsupported { .. }) + ), + "expected refusal for status {status:?}" + ); + } + } } diff --git a/git-workon-review/tests/line_synthesis.rs b/git-workon-review/tests/line_synthesis.rs new file mode 100644 index 0000000..760541d --- /dev/null +++ b/git-workon-review/tests/line_synthesis.rs @@ -0,0 +1,184 @@ +//! Line-precise patch synthesis round-trips (trap 1: direction-dependent drop rules), run +//! against both appliers via `for_each_applier` — see `tests/apply.rs` for the pattern this +//! borrows (fresh fixture per applier backend). +//! +//! Trap-2 (the EOFNL splice) round-trips land in a follow-up commit. + +use git_workon_fixture::prelude::*; +use workon_review::acquire::diff_uncommitted; +use workon_review::apply::{ + Applier, ApplyDestination, ApplyDirection, CliApplier, Git2Applier, StageVerb, +}; +use workon_review::model::{FileChange, LineKind}; +use workon_review::synthesis::{partial_hunk_patch, LineSelection, PatchBase}; + +/// Run `test` once per applier backend. Each invocation gets its own closure body so callers +/// build a fresh fixture inside `test` rather than sharing one across backends (appliers mutate +/// live repository state). +fn for_each_applier(mut test: impl FnMut(&dyn Applier)) { + test(&Git2Applier); + test(&CliApplier); +} + +/// Find the `hunk.lines` index of the first line of `kind` whose content matches `content` +/// exactly — lets tests key a [`LineSelection`] off readable content instead of hard-coded +/// positions that would silently drift if git2's line ordering ever changed. +fn line_index(file: &FileChange, hunk_idx: usize, kind: LineKind, content: &str) -> usize { + file.hunks[hunk_idx] + .lines + .iter() + .position(|l| l.kind == kind && l.content == content.as_bytes()) + .unwrap_or_else(|| panic!("no {kind:?} line with content {content:?} in hunk {hunk_idx}")) +} + +/// Two separate changes ("old2"->"new2", "old4"->"new4") in one hunk, separated by a context +/// line — the shape that exercises the direction rules: keeping one change and dropping the +/// other must not affect the untouched change identically under both directions. +fn two_change_fixture() -> FixtureBuilder<'static> { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "f.txt", + "line1\nold2\nline3\nold4\nline5\n", + "line1\nnew2\nline3\nnew4\nline5\n", + ) +} + +#[test] +fn stage_partial_updates_only_the_kept_change() { + for_each_applier(|applier| { + let fixture = two_change_fixture().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 keep_add = line_index(file, 0, LineKind::Addition, "new2\n"); + let keep_del = line_index(file, 0, LineKind::Deletion, "old2\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [keep_del].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::Old).expect("partial_hunk_patch"); + + let (_, dest, dir) = StageVerb::Stage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + // Only the first change landed in the index; the second change is still absent there. + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nnew2\nline3\nold4\nline5\n".to_vec(), + )); + // The Index-only apply must not touch the working tree, which still carries the full + // unstaged modification (both changes). + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"line1\nnew2\nline3\nnew4\nline5\n".to_vec(), + )); + }); +} + +#[test] +fn unstage_partial_removes_only_the_kept_change_from_the_index() { + for_each_applier(|applier| { + let fixture = two_change_fixture().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + // Stage the FULL modification first (index := workdir for this path), so the staged + // (tree_to_index) model — the correct preimage for an Unstage patch (plan risk #3) — + // sees both changes. + let mut index = repo.index().expect("index"); + index + .add_path(std::path::Path::new("f.txt")) + .expect("add_path"); + index.write().expect("index write"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.staged.files[0]; + let keep_add = line_index(file, 0, LineKind::Addition, "new2\n"); + let keep_del = line_index(file, 0, LineKind::Deletion, "old2\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [keep_del].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::New).expect("partial_hunk_patch"); + + let (_, dest, dir) = StageVerb::Unstage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + // "Kept" means "operated on": unstaging keep={old2/new2} removes just that change from + // the index, reverting it to committed content, while the second (dropped/untouched) + // change stays staged. + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nold2\nline3\nnew4\nline5\n".to_vec(), + )); + }); +} + +#[test] +fn discard_partial_reverts_only_the_kept_change_in_the_workdir() { + for_each_applier(|applier| { + let fixture = two_change_fixture().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 keep_add = line_index(file, 0, LineKind::Addition, "new2\n"); + let keep_del = line_index(file, 0, LineKind::Deletion, "old2\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [keep_del].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::New).expect("partial_hunk_patch"); + + let (_, dest, dir) = StageVerb::Discard.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"line1\nold2\nline3\nnew4\nline5\n".to_vec(), + )); + // Nothing was ever staged in this scenario — the index still matches HEAD verbatim. + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nold2\nline3\nold4\nline5\n".to_vec(), + )); + }); +} + +/// Trap-1's guard: a `base=Old` partial patch encodes a DIFFERENT, incompatible set of drop +/// rules than a `base=New` one (dropped-add-omitted/dropped-del-context vs. the mirror). Forcing +/// it through a reverse apply must fail outright rather than silently produce a wrong result — +/// this is what proves the direction rules are load-bearing, not cosmetic. +#[test] +fn base_old_partial_patch_fails_under_reverse_apply() { + let fixture = two_change_fixture().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 keep_add = line_index(file, 0, LineKind::Addition, "new2\n"); + let keep_del = line_index(file, 0, LineKind::Deletion, "old2\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [keep_del].into(), + }; + // Deliberately wrong: synthesize with base=Old (Stage's rules), then force a reverse apply + // (Unstage's direction) instead of a forward one. + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::Old).expect("partial_hunk_patch"); + + let result = CliApplier.apply( + repo, + &patch, + ApplyDestination::Index, + ApplyDirection::Reverse, + ); + + assert!( + matches!( + result, + Err(workon_review::error::ApplyError::CliApplyFailed { .. }) + ), + "expected reverse-applying a base=Old partial patch to fail, got {result:?}" + ); +} From 884f04d1ce57ba24922da858d5c3fbc5bbd9053e Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 00:47:08 -0400 Subject: [PATCH 010/203] fix(review): splice EOFNL del-to-context lines in partial patches --- git-workon-review/src/synthesis.rs | 141 ++++++++++++--- git-workon-review/tests/line_synthesis.rs | 211 +++++++++++++++++++++- 2 files changed, 317 insertions(+), 35 deletions(-) diff --git a/git-workon-review/src/synthesis.rs b/git-workon-review/src/synthesis.rs index df78e84..5568076 100644 --- a/git-workon-review/src/synthesis.rs +++ b/git-workon-review/src/synthesis.rs @@ -267,6 +267,68 @@ pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result) -> Vec { + let last = emitted.len().saturating_sub(1); + let mut out = Vec::with_capacity(emitted.len()); + for (i, (line, dropped_del_context)) in emitted.into_iter().enumerate() { + if dropped_del_context && line.missing_newline && i != last { + let mut readded = line.content.clone(); + readded.push(b'\n'); + out.push(PatchLine { + kind: LineKind::Deletion, + content: line.content, + missing_newline: true, + }); + out.push(PatchLine { + kind: LineKind::Addition, + content: readded, + missing_newline: false, + }); + } else { + out.push(line); + } + } + out +} + /// Which of a hunk's addition/deletion lines to keep in a line-precise patch. /// /// Indices are into [`crate::model::Hunk::lines`] — the `Vec` position, NOT the old/new line @@ -320,9 +382,10 @@ pub struct LineSelection { /// unsupported statuses ([`SynthesisError::LineSelectionUnsupported`]), and an out-of-range /// `hunk_idx` ([`SynthesisError::HunkOutOfRange`]). /// -/// This function does not yet apply the trap-2 EOFNL splice (a dropped deletion converted to -/// context that carries [`crate::model::HunkLine::missing_newline`], followed by any kept -/// line, silently corrupts the blob under `git apply`) — see the follow-up commit. +/// A dropped deletion converted to context (see above) that carries +/// [`crate::model::HunkLine::missing_newline`], followed by any other emitted line, is spliced +/// by [`splice_eofnl_context_lines`] into git's canonical delete+re-add form rather than left as +/// a raw context line — see that function's docs for why (trap 2). pub fn partial_hunk_patch( file: &FileChange, hunk_idx: usize, @@ -354,38 +417,50 @@ pub fn partial_hunk_patch( let mut kept_any = false; let mut old_count = 0u32; let mut new_count = 0u32; - let mut lines = Vec::with_capacity(hunk.lines.len()); + // Each entry pairs the emitted line with whether it's a dropped-DELETION-turned-context + // line — the only shape the trap-2 splice (below) ever needs to consider — kept as one + // `push` per line so the two can't drift out of sync with each other. + let mut emitted: Vec<(PatchLine, bool)> = Vec::with_capacity(hunk.lines.len()); for (idx, line) in hunk.lines.iter().enumerate() { match line.kind { LineKind::Context => { old_count += 1; new_count += 1; - lines.push(PatchLine { - kind: LineKind::Context, - content: line.content.clone(), - missing_newline: line.missing_newline, - }); + emitted.push(( + PatchLine { + kind: LineKind::Context, + content: line.content.clone(), + missing_newline: line.missing_newline, + }, + false, + )); } LineKind::Addition => { if sel.keep_adds.contains(&idx) { kept_any = true; new_count += 1; - lines.push(PatchLine { - kind: LineKind::Addition, - content: line.content.clone(), - missing_newline: line.missing_newline, - }); + emitted.push(( + PatchLine { + kind: LineKind::Addition, + content: line.content.clone(), + missing_newline: line.missing_newline, + }, + false, + )); } else if base == PatchBase::New { // Dropped addition, base=New: it must remain in the (already-changed) // target, so it has to match as context on reverse-apply. old_count += 1; new_count += 1; - lines.push(PatchLine { - kind: LineKind::Context, - content: line.content.clone(), - missing_newline: line.missing_newline, - }); + emitted.push(( + PatchLine { + kind: LineKind::Context, + content: line.content.clone(), + missing_newline: line.missing_newline, + }, + false, + )); } // base=Old: dropped addition is omitted — the target doesn't have it yet and // shouldn't gain it. @@ -394,21 +469,27 @@ pub fn partial_hunk_patch( if sel.keep_dels.contains(&idx) { kept_any = true; old_count += 1; - lines.push(PatchLine { - kind: LineKind::Deletion, - content: line.content.clone(), - missing_newline: line.missing_newline, - }); + emitted.push(( + PatchLine { + kind: LineKind::Deletion, + content: line.content.clone(), + missing_newline: line.missing_newline, + }, + false, + )); } else if base == PatchBase::Old { // Dropped deletion, base=Old: it's still there in the target, so it has to // match as context. old_count += 1; new_count += 1; - lines.push(PatchLine { - kind: LineKind::Context, - content: line.content.clone(), - missing_newline: line.missing_newline, - }); + emitted.push(( + PatchLine { + kind: LineKind::Context, + content: line.content.clone(), + missing_newline: line.missing_newline, + }, + true, + )); } // base=New: dropped deletion is omitted — it's already absent from the target // and must never be matched against. @@ -423,6 +504,8 @@ pub fn partial_hunk_patch( }); } + let lines = splice_eofnl_context_lines(emitted); + let mut header = format!( "@@ -{},{old_count} +{},{new_count} @@", hunk.old_start, hunk.new_start diff --git a/git-workon-review/tests/line_synthesis.rs b/git-workon-review/tests/line_synthesis.rs index 760541d..216f709 100644 --- a/git-workon-review/tests/line_synthesis.rs +++ b/git-workon-review/tests/line_synthesis.rs @@ -1,8 +1,6 @@ -//! Line-precise patch synthesis round-trips (trap 1: direction-dependent drop rules), run -//! against both appliers via `for_each_applier` — see `tests/apply.rs` for the pattern this -//! borrows (fresh fixture per applier backend). -//! -//! Trap-2 (the EOFNL splice) round-trips land in a follow-up commit. +//! Line-precise patch synthesis round-trips (trap 1: direction-dependent drop rules; trap 2: +//! the EOFNL del-to-context splice), run against both appliers via `for_each_applier` — see +//! `tests/apply.rs` for the pattern this borrows (fresh fixture per applier backend). use git_workon_fixture::prelude::*; use workon_review::acquire::diff_uncommitted; @@ -10,7 +8,9 @@ use workon_review::apply::{ Applier, ApplyDestination, ApplyDirection, CliApplier, Git2Applier, StageVerb, }; use workon_review::model::{FileChange, LineKind}; -use workon_review::synthesis::{partial_hunk_patch, LineSelection, PatchBase}; +use workon_review::synthesis::{ + partial_hunk_patch, LineSelection, PatchBase, PatchHunk, PatchLine, PatchText, +}; /// Run `test` once per applier backend. Each invocation gets its own closure body so callers /// build a fresh fixture inside `test` rather than sharing one across backends (appliers mutate @@ -182,3 +182,202 @@ fn base_old_partial_patch_fails_under_reverse_apply() { "expected reverse-applying a base=Old partial patch to fail, got {result:?}" ); } + +/// Fixture for the trap-2 (EOFNL splice) tests: the committed file's last line ("last") has NO +/// trailing newline; the modification deletes that line and adds two new ones, the last of +/// which ("more\n") DOES end in a newline (so the file gains a trailing newline overall). This +/// is the shape that produces a deletion carrying `missing_newline` with kept lines after it — +/// trap 2's precondition. +fn eofnl_fixture() -> FixtureBuilder<'static> { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "a\nb\nlast", "a\nb\nreplaced\nmore\n") +} + +/// Hand-built patch replicating what `partial_hunk_patch` would have produced BEFORE the trap-2 +/// splice: the dropped deletion of "last" rendered as a plain context line, still carrying its +/// `missing_newline` marker, immediately followed by the kept "+more" addition. This is a +/// test-only stand-in for the naive (pre-fix) code path — there's no live way to ask the current +/// `partial_hunk_patch` for it, since the splice isn't optional. +fn naive_unspliced_patch() -> PatchText { + PatchText { + old_path: Some("f.txt".to_string()), + new_path: Some("f.txt".to_string()), + hunks: vec![PatchHunk { + old_start: 1, + old_count: 3, + new_start: 1, + new_count: 4, + header: b"@@ -1,3 +1,4 @@\n".to_vec(), + lines: vec![ + PatchLine { + kind: LineKind::Context, + content: b"a\n".to_vec(), + missing_newline: false, + }, + PatchLine { + kind: LineKind::Context, + content: b"b\n".to_vec(), + missing_newline: false, + }, + PatchLine { + kind: LineKind::Context, + content: b"last".to_vec(), + missing_newline: true, + }, + PatchLine { + kind: LineKind::Addition, + content: b"more\n".to_vec(), + missing_newline: false, + }, + ], + }], + } +} + +/// THE TRIPWIRE, first: documents that `git apply` does NOT reject the naive (unspliced) form — +/// it exits 0 and silently concatenates "more" directly onto "last" with no separating newline, +/// corrupting the blob. Verified against the system `git` binary; pinned here exactly so a +/// future git version that starts rejecting (or otherwise changes) this shape is caught by a +/// test failure instead of a passing suite over a stale assumption. +#[test] +fn naive_unspliced_eofnl_patch_silently_corrupts_the_index() { + let fixture = eofnl_fixture().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let patch = naive_unspliced_patch(); + + let result = CliApplier.apply( + repo, + &patch, + ApplyDestination::Index, + ApplyDirection::Forward, + ); + + assert!( + result.is_ok(), + "expected git apply to silently accept the naive form, got {result:?}" + ); + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"a\nb\nlastmore\n".to_vec(), + )); +} + +#[test] +fn spliced_eofnl_patch_stages_correct_bytes() { + for_each_applier(|applier| { + let fixture = eofnl_fixture().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + // Keep only "more\n"; drop the "last" deletion (context, missing_newline) and the + // "replaced\n" addition (omitted under base=Old) — trap 2's exact precondition. + let keep_add = line_index(file, 0, LineKind::Addition, "more\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::Old).expect("partial_hunk_patch"); + + let (_, dest, dir) = StageVerb::Stage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"a\nb\nlast\nmore\n".to_vec(), + )); + }); +} + +/// The spliced patch must still be a well-formed unified diff, not just something `git apply` +/// happens to tolerate — `git2::Diff::from_buffer` parses stricter (plan risk #4). +#[test] +fn spliced_eofnl_patch_reparses_via_git2_from_buffer() { + let fixture = eofnl_fixture().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 keep_add = line_index(file, 0, LineKind::Addition, "more\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::Old).expect("partial_hunk_patch"); + + git2::Diff::from_buffer(&patch.to_bytes()).expect("spliced patch reparses"); +} + +/// The splice-NOT-needed case: the dropped no-newline deletion is the LAST emitted line (nothing +/// kept comes after it), so it renders as plain context and applies cleanly with no splice. +#[test] +fn dropped_eofnl_deletion_as_last_line_needs_no_splice() { + for_each_applier(|applier| { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "x\ny\nlast", "X\ny\nreplacedlast") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + // Keep the "x"->"X" change; drop the "last"->"replacedlast" change entirely (its + // deletion converts to context under base=Old, its addition is omitted) — the dropped + // deletion ends up as the LAST emitted line. + let keep_add = line_index(file, 0, LineKind::Addition, "X\n"); + let keep_del = line_index(file, 0, LineKind::Deletion, "x\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [keep_del].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::Old).expect("partial_hunk_patch"); + + let (_, dest, dir) = StageVerb::Stage.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"X\ny\nlast".to_vec(), + )); + }); +} + +/// The base=New mirror: a dropped ADDITION carrying `missing_newline` converts to context. Per +/// [`workon_review::synthesis`]'s doc comment on `splice_eofnl_context_lines`, this can never +/// have kept lines after it — `missing_newline` is only ever set on a file's true last line, and +/// synthesis never reorders `hunk.lines` — so it needs no splice. This test is the evidence for +/// that reasoning: the committed file HAS a trailing newline, the modification drops it (the +/// addition "replaced" is the new EOF); discarding while dropping that addition must reproduce +/// the original content without corruption. +#[test] +fn dropped_eofnl_addition_as_context_needs_no_splice_under_base_new() { + for_each_applier(|applier| { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "a\nb\nlast\n", "a\nb\nreplaced") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + // Keep the deletion of "last\n" (restore it on discard); drop the addition "replaced" + // (base=New converts it to context — it's the file's new EOF, so nothing follows it). + let keep_del = line_index(file, 0, LineKind::Deletion, "last\n"); + let sel = LineSelection { + keep_adds: [].into(), + keep_dels: [keep_del].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::New).expect("partial_hunk_patch"); + + let (_, dest, dir) = StageVerb::Discard.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"a\nb\nlast\nreplaced".to_vec(), + )); + }); +} From 9c815d62b526da12d02070083124f88baec22fab Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 17:40:14 -0400 Subject: [PATCH 011/203] fix(review): splice kept-deletion EOFNL and thread patch mode --- git-workon-review/src/synthesis.rs | 245 ++++++++++++++-------- git-workon-review/tests/line_synthesis.rs | 43 ++++ 2 files changed, 199 insertions(+), 89 deletions(-) diff --git a/git-workon-review/src/synthesis.rs b/git-workon-review/src/synthesis.rs index 5568076..a0f8934 100644 --- a/git-workon-review/src/synthesis.rs +++ b/git-workon-review/src/synthesis.rs @@ -13,7 +13,7 @@ use std::collections::BTreeSet; use crate::error::SynthesisError; -use crate::model::{FileChange, FileStatus, LineKind}; +use crate::model::{FileChange, FileStatus, Hunk, LineKind}; /// Which side of a patch is the "before" image — the direction-dependent drop rules (trap 1) /// key off this. Whole-hunk patches don't drop lines, so `PatchBase` only affects @@ -202,12 +202,13 @@ impl PatchText { } } -/// Synthesize a patch for the WHOLE of `file`'s hunk at `hunk_idx` — no line selection, so the -/// direction-dependent drop rules (trap 1) don't apply; the hunk's lines are copied verbatim. +/// Shared guard preamble for [`whole_hunk_patch`] and [`partial_hunk_patch`]: refuse binary +/// files and statuses a hunk patch can't express, look up `hunk_idx`, and derive the +/// old/new path labels — extracted so the two synthesis entry points can't drift apart on +/// these checks. /// /// Refuses: /// - binary files ([`SynthesisError::BinaryFile`]) — no hunks exist to synthesize from. -/// - `hunk_idx` out of range ([`SynthesisError::HunkOutOfRange`]). /// - 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 @@ -216,7 +217,11 @@ impl PatchText { /// `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`). -pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result { +/// - `hunk_idx` out of range ([`SynthesisError::HunkOutOfRange`]). +fn selectable_hunk( + file: &FileChange, + hunk_idx: usize, +) -> Result<(&Hunk, String, String), SynthesisError> { if file.is_binary { return Err(SynthesisError::BinaryFile { path: file.path.clone(), @@ -241,6 +246,16 @@ pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result Result { + let (hunk, old_path, new_path) = selectable_hunk(file, hunk_idx)?; + let lines = hunk .lines .iter() @@ -267,9 +282,34 @@ pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result Result Result) -> Vec { +/// The `KeptDeletion` fix rewrites the line's own content in place (append a real `\n`, clear +/// `missing_newline`) — no companion line, and no change to the hunk's old/new counts, since a +/// kept deletion only ever contributed to the old side either way. +/// +/// A dropped ADDITION converted to context (`base == New`) can never itself need either fix: +/// [`crate::model::HunkLine::missing_newline`] is only ever set on a line that is the true last +/// line of the file (see the module docs on the EOFNL characterization), and +/// [`partial_hunk_patch`] never reorders `hunk.lines` — so an addition carrying the flag is +/// always the LAST entry synthesized for its hunk, with nothing emitted after it to corrupt +/// against. `tests/line_synthesis.rs` has a test pinning this reasoning against a real apply +/// rather than asserting it blind. +fn splice_eofnl_context_lines(emitted: Vec<(PatchLine, SpliceNeed)>) -> Vec { + // Suffix scan: `later_has_context[i]` is true iff some `emitted[j]` with `j > i` is a + // Context line — computed once up front (over the PRE-splice kinds) so the loop below can + // ask "is anything dangerous coming after me" without repeated rescans. + let mut later_has_context = vec![false; emitted.len()]; + let mut seen_context = false; + for i in (0..emitted.len()).rev() { + later_has_context[i] = seen_context; + if emitted[i].0.kind == LineKind::Context { + seen_context = true; + } + } + let last = emitted.len().saturating_sub(1); let mut out = Vec::with_capacity(emitted.len()); - for (i, (line, dropped_del_context)) in emitted.into_iter().enumerate() { - if dropped_del_context && line.missing_newline && i != last { - let mut readded = line.content.clone(); - readded.push(b'\n'); - out.push(PatchLine { - kind: LineKind::Deletion, - content: line.content, - missing_newline: true, - }); - out.push(PatchLine { - kind: LineKind::Addition, - content: readded, - missing_newline: false, - }); - } else { - out.push(line); + for (i, (line, need)) in emitted.into_iter().enumerate() { + match need { + SpliceNeed::ConvertedContext if line.missing_newline && i != last => { + let mut readded = line.content.clone(); + readded.push(b'\n'); + out.push(PatchLine { + kind: LineKind::Deletion, + content: line.content, + missing_newline: true, + }); + out.push(PatchLine { + kind: LineKind::Addition, + content: readded, + missing_newline: false, + }); + } + SpliceNeed::KeptDeletion if line.missing_newline && later_has_context[i] => { + let mut content = line.content; + content.push(b'\n'); + out.push(PatchLine { + kind: LineKind::Deletion, + content, + missing_newline: false, + }); + } + _ => out.push(line), } } out @@ -382,84 +462,58 @@ pub struct LineSelection { /// unsupported statuses ([`SynthesisError::LineSelectionUnsupported`]), and an out-of-range /// `hunk_idx` ([`SynthesisError::HunkOutOfRange`]). /// -/// A dropped deletion converted to context (see above) that carries -/// [`crate::model::HunkLine::missing_newline`], followed by any other emitted line, is spliced -/// by [`splice_eofnl_context_lines`] into git's canonical delete+re-add form rather than left as -/// a raw context line — see that function's docs for why (trap 2). +/// 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 +/// delete+re-add form rather than left as a raw context/deletion line — see that function's docs +/// for why (trap 2). pub fn partial_hunk_patch( file: &FileChange, hunk_idx: usize, sel: &LineSelection, base: PatchBase, ) -> Result { - if file.is_binary { - return Err(SynthesisError::BinaryFile { - path: file.path.clone(), - }); - } - match file.status { - FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied => {} - other => { - return Err(SynthesisError::LineSelectionUnsupported { - path: file.path.clone(), - status: other, - }) - } - } - let hunk = file - .hunks - .get(hunk_idx) - .ok_or_else(|| SynthesisError::HunkOutOfRange { - path: file.path.clone(), - index: hunk_idx, - })?; + let (hunk, old_path, new_path) = selectable_hunk(file, hunk_idx)?; let mut kept_any = false; - let mut old_count = 0u32; - let mut new_count = 0u32; - // Each entry pairs the emitted line with whether it's a dropped-DELETION-turned-context - // line — the only shape the trap-2 splice (below) ever needs to consider — kept as one - // `push` per line so the two can't drift out of sync with each other. - let mut emitted: Vec<(PatchLine, bool)> = Vec::with_capacity(hunk.lines.len()); + // Each entry pairs the emitted line with which trap-2 splice check (if any) it needs — see + // [`SpliceNeed`] — kept as one `push` per line so the two can't drift out of sync with each + // other. + let mut emitted: Vec<(PatchLine, SpliceNeed)> = Vec::with_capacity(hunk.lines.len()); for (idx, line) in hunk.lines.iter().enumerate() { match line.kind { LineKind::Context => { - old_count += 1; - new_count += 1; emitted.push(( PatchLine { kind: LineKind::Context, content: line.content.clone(), missing_newline: line.missing_newline, }, - false, + SpliceNeed::None, )); } LineKind::Addition => { if sel.keep_adds.contains(&idx) { kept_any = true; - new_count += 1; emitted.push(( PatchLine { kind: LineKind::Addition, content: line.content.clone(), missing_newline: line.missing_newline, }, - false, + SpliceNeed::None, )); } else if base == PatchBase::New { // Dropped addition, base=New: it must remain in the (already-changed) // target, so it has to match as context on reverse-apply. - old_count += 1; - new_count += 1; emitted.push(( PatchLine { kind: LineKind::Context, content: line.content.clone(), missing_newline: line.missing_newline, }, - false, + SpliceNeed::None, )); } // base=Old: dropped addition is omitted — the target doesn't have it yet and @@ -468,27 +522,24 @@ pub fn partial_hunk_patch( LineKind::Deletion => { if sel.keep_dels.contains(&idx) { kept_any = true; - old_count += 1; emitted.push(( PatchLine { kind: LineKind::Deletion, content: line.content.clone(), missing_newline: line.missing_newline, }, - false, + SpliceNeed::KeptDeletion, )); } else if base == PatchBase::Old { // Dropped deletion, base=Old: it's still there in the target, so it has to // match as context. - old_count += 1; - new_count += 1; emitted.push(( PatchLine { kind: LineKind::Context, content: line.content.clone(), missing_newline: line.missing_newline, }, - true, + SpliceNeed::ConvertedContext, )); } // base=New: dropped deletion is omitted — it's already absent from the target @@ -506,6 +557,19 @@ pub fn partial_hunk_patch( let lines = splice_eofnl_context_lines(emitted); + // Counts are derived from the FINAL (post-splice) lines, not accumulated during the + // selection loop above — the trap-2 splice can grow a single kept deletion into a + // deletion+addition pair, which would otherwise leave the header's `new_count` short by + // one and produce a hunk whose declared counts don't match its body. + let old_count: u32 = lines + .iter() + .filter(|l| matches!(l.kind, LineKind::Context | LineKind::Deletion)) + .count() as u32; + let new_count: u32 = lines + .iter() + .filter(|l| matches!(l.kind, LineKind::Context | LineKind::Addition)) + .count() as u32; + let mut header = format!( "@@ -{},{old_count} +{},{new_count} @@", hunk.old_start, hunk.new_start @@ -513,12 +577,11 @@ pub fn partial_hunk_patch( .into_bytes(); header.extend_from_slice(&header_suffix(&hunk.header)); - let old_path = file.old_path.clone().unwrap_or_else(|| file.path.clone()); - let new_path = file.path.clone(); - Ok(PatchText { old_path: Some(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_count, @@ -916,6 +979,8 @@ mod tests { old_path: None, status: FileStatus::Modified, is_binary: true, + old_mode: 0o100644, + new_mode: 0o100644, hunks: vec![], }; assert!(matches!( @@ -946,6 +1011,8 @@ mod tests { old_path: None, status, is_binary: false, + old_mode: 0o100644, + new_mode: 0o100644, hunks: vec![two_change_hunk()], }; assert!( diff --git a/git-workon-review/tests/line_synthesis.rs b/git-workon-review/tests/line_synthesis.rs index 216f709..9da208e 100644 --- a/git-workon-review/tests/line_synthesis.rs +++ b/git-workon-review/tests/line_synthesis.rs @@ -203,6 +203,8 @@ fn naive_unspliced_patch() -> PatchText { PatchText { old_path: Some("f.txt".to_string()), new_path: Some("f.txt".to_string()), + old_mode: 0o100644, + new_mode: 0o100644, hunks: vec![PatchHunk { old_start: 1, old_count: 3, @@ -381,3 +383,44 @@ fn dropped_eofnl_addition_as_context_needs_no_splice_under_base_new() { )); }); } + +/// The bug this fix targets: a KEPT deletion (not a dropped-to-context one) carrying +/// `missing_newline` under `base == New`, followed by dropped-additions-turned-context. Before +/// the fix, `partial_hunk_patch` emitted the kept deletion verbatim (never routed through the +/// trap-2 splice, which only ever looked at dropped-deletion-turned-context lines) — the CLI +/// applier silently concatenated the next line onto it (`"lastreplaced\n"`), while the git2 +/// applier rejected the patch outright (`invalid patch hunk`). Same fixture as +/// `dropped_eofnl_addition_as_context_needs_no_splice_under_base_new`'s mirror +/// (`eofnl_fixture`), but this time KEEPING the "last" deletion (instead of dropping it) so it +/// stays a `Deletion` line rather than converting to context. +#[test] +fn kept_eofnl_deletion_needs_splice_under_base_new() { + for_each_applier(|applier| { + let fixture = eofnl_fixture().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + // Keep the deletion of "last" (no trailing newline); drop both additions + // ("replaced\n"/"more\n") — base=New converts each to context, so the kept deletion is + // followed by more emitted lines. + let keep_del = line_index(file, 0, LineKind::Deletion, "last"); + let sel = LineSelection { + keep_adds: [].into(), + keep_dels: [keep_del].into(), + }; + let patch = partial_hunk_patch(file, 0, &sel, PatchBase::New).expect("partial_hunk_patch"); + + // The spliced patch must still be well-formed enough for git2 to reparse (plan risk #4) + // — before the fix, git2 rejected this shape outright. + git2::Diff::from_buffer(&patch.to_bytes()).expect("spliced patch reparses"); + + let (_, dest, dir) = StageVerb::Discard.plan(); + applier.apply(repo, &patch, dest, dir).expect("apply"); + + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"a\nb\nlast\nreplaced\nmore\n".to_vec(), + )); + }); +} From f4cbaf0d276bb0e798f686f65254af824376c0d8 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 00:58:12 -0400 Subject: [PATCH 012/203] feat(review): route whole-file ops for added and deleted files --- git-workon-fixture/src/fixture_builder.rs | 98 ++++- git-workon-fixture/tests/suite/index_state.rs | 60 +++ git-workon-review/src/file_ops.rs | 76 ++++ git-workon-review/src/lib.rs | 8 +- git-workon-review/src/ops.rs | 109 +++++ git-workon-review/tests/file_ops.rs | 410 ++++++++++++++++++ 6 files changed, 749 insertions(+), 12 deletions(-) create mode 100644 git-workon-review/src/file_ops.rs create mode 100644 git-workon-review/src/ops.rs create mode 100644 git-workon-review/tests/file_ops.rs diff --git a/git-workon-fixture/src/fixture_builder.rs b/git-workon-fixture/src/fixture_builder.rs index f3ccf9e..e67c27c 100644 --- a/git-workon-fixture/src/fixture_builder.rs +++ b/git-workon-fixture/src/fixture_builder.rs @@ -145,6 +145,8 @@ pub struct FixtureBuilder<'fixture> { unstaged_files: Vec<(String, String, String)>, // (path, committed, modified) untracked_files: Vec<(String, String)>, // (path, content) deleted_files: Vec<(String, String)>, // (path, committed) + partially_staged_files: Vec<(String, String, String, String)>, // (path, committed, staged, workdir) + untracked_symlinks: Vec<(String, String)>, // (path, target) — target need not exist } impl<'fixture> FixtureBuilder<'fixture> { @@ -166,6 +168,8 @@ impl<'fixture> FixtureBuilder<'fixture> { unstaged_files: Vec::new(), untracked_files: Vec::new(), deleted_files: Vec::new(), + partially_staged_files: Vec::new(), + untracked_symlinks: Vec::new(), } } @@ -378,6 +382,45 @@ impl<'fixture> FixtureBuilder<'fixture> { self } + /// Commit `path` with `committed` content on the cwd repo's branch during `build()` (same + /// baseline-commit block as [`unstaged_file`](Self::unstaged_file)/[`deleted_file`](Self::deleted_file)), + /// then stage `staged` content (index entry differs from `HEAD`), then rewrite the working + /// tree copy to `workdir` (differs from BOTH `HEAD` and the index) — three genuinely + /// distinct states for `HEAD`/index/workdir, needed to test operations (like `discard`) that + /// must revert to the INDEX's content specifically, not `HEAD`'s. + /// + /// 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 partially_staged_file( + mut self, + path: &str, + committed: &str, + staged: &str, + workdir: &str, + ) -> Self { + self.partially_staged_files.push(( + path.to_string(), + committed.to_string(), + staged.to_string(), + workdir.to_string(), + )); + self + } + + /// Create a symlink at `path` pointing at `target` in the fixture's cwd repo working tree; + /// never staged (untracked). `target` need not exist — a dangling/broken symlink is still a + /// real working-tree entry (`symlink_metadata`/lstat sees it; `Path::exists`, which follows + /// the link, does not). + /// + /// Unix-only ([`std::os::unix::fs::symlink`]); 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 untracked_symlink(mut self, path: &str, target: &str) -> Self { + self.untracked_symlinks + .push((path.to_string(), target.to_string())); + self + } + pub fn build(self) -> Result { isolate_ambient_git_config(); let tmpdir = TempDir::new()?; @@ -485,20 +528,25 @@ impl<'fixture> FixtureBuilder<'fixture> { let has_index_state = !self.staged_files.is_empty() || !self.unstaged_files.is_empty() || !self.untracked_files.is_empty() - || !self.deleted_files.is_empty(); + || !self.deleted_files.is_empty() + || !self.partially_staged_files.is_empty() + || !self.untracked_symlinks.is_empty(); if has_index_state && self.bare && self.worktrees.is_empty() { return Err( - "staged_file/unstaged_file/untracked_file/deleted_file require a working tree: \ - fixture is bare(true) with no worktree" + "staged_file/unstaged_file/untracked_file/deleted_file/partially_staged_file/\ + untracked_symlink require a working tree: fixture is bare(true) with no worktree" .into(), ); } - // `unstaged_file`/`deleted_file` baseline commits land BEFORE Graphite-metadata - // live-tip resolution below: they move the cwd branch's tip, and any metadata entry - // recording that tip must reflect the moved one, not the pre-baseline commit. Both - // builders share one baseline commit. - if !self.unstaged_files.is_empty() || !self.deleted_files.is_empty() { + // `unstaged_file`/`deleted_file`/`partially_staged_file` baseline commits land BEFORE + // Graphite-metadata live-tip resolution below: they move the cwd branch's tip, and any + // metadata entry recording that tip must reflect the moved one, not the pre-baseline + // commit. All three builders share one baseline commit. + if !self.unstaged_files.is_empty() + || !self.deleted_files.is_empty() + || !self.partially_staged_files.is_empty() + { let cwd_repo = Repository::open(&cwd_path)?; let mut index = cwd_repo.index()?; for (file_path, committed, _modified) in &self.unstaged_files { @@ -517,6 +565,14 @@ impl<'fixture> FixtureBuilder<'fixture> { std::fs::write(&abs_path, committed)?; index.add_path(Path::new(file_path))?; } + for (file_path, committed, _staged, _workdir) in &self.partially_staged_files { + 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, committed)?; + index.add_path(Path::new(file_path))?; + } index.write()?; let tree_id = index.write_tree()?; @@ -677,7 +733,7 @@ impl<'fixture> FixtureBuilder<'fixture> { if has_index_state { let cwd_repo = Repository::open(&cwd_path)?; - if !self.staged_files.is_empty() { + if !self.staged_files.is_empty() || !self.partially_staged_files.is_empty() { let mut index = cwd_repo.index()?; for (file_path, content) in &self.staged_files { let abs_path = cwd_path.join(file_path); @@ -687,6 +743,11 @@ impl<'fixture> FixtureBuilder<'fixture> { std::fs::write(&abs_path, content)?; index.add_path(Path::new(file_path))?; } + for (file_path, _committed, staged, _workdir) in &self.partially_staged_files { + let abs_path = cwd_path.join(file_path); + std::fs::write(&abs_path, staged)?; + index.add_path(Path::new(file_path))?; + } index.write()?; } @@ -694,6 +755,12 @@ impl<'fixture> FixtureBuilder<'fixture> { std::fs::write(cwd_path.join(file_path), modified)?; } + // Rewrite the working tree copy to `workdir` content AFTER the index has `staged` + // — the index entry must stay at `staged`, only the on-disk file moves further. + for (file_path, _committed, _staged, workdir) in &self.partially_staged_files { + std::fs::write(cwd_path.join(file_path), workdir)?; + } + for (file_path, content) in &self.untracked_files { let abs_path = cwd_path.join(file_path); if let Some(parent) = abs_path.parent() { @@ -705,6 +772,19 @@ impl<'fixture> FixtureBuilder<'fixture> { for (file_path, _committed) in &self.deleted_files { std::fs::remove_file(cwd_path.join(file_path))?; } + + #[cfg(unix)] + for (file_path, target) in &self.untracked_symlinks { + let abs_path = cwd_path.join(file_path); + if let Some(parent) = abs_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::os::unix::fs::symlink(target, &abs_path)?; + } + #[cfg(not(unix))] + if !self.untracked_symlinks.is_empty() { + return Err("untracked_symlink is unix-only".into()); + } } if self.worktrees.is_empty() { diff --git a/git-workon-fixture/tests/suite/index_state.rs b/git-workon-fixture/tests/suite/index_state.rs index ca34af6..95330f2 100644 --- a/git-workon-fixture/tests/suite/index_state.rs +++ b/git-workon-fixture/tests/suite/index_state.rs @@ -341,3 +341,63 @@ fn deleted_file_baseline_commit_lands_before_metadata_resolution( Ok(()) } + +#[test] +fn partially_staged_file_has_three_distinct_states() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .partially_staged_file("f.txt", "committed", "staged", "workdir") + .build()?; + + let repo = fixture.repo()?; + repo.assert(predicate::repo::index_blob_equals( + "f.txt", + b"staged".to_vec(), + )); + repo.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"workdir".to_vec(), + )); + + let head_commit = repo.head()?.peel_to_commit()?; + let tree = head_commit.tree()?; + let entry = tree.get_path(std::path::Path::new("f.txt"))?; + let blob = repo.find_blob(entry.id())?; + assert_eq!(blob.content(), b"committed"); + + Ok(()) +} + +#[test] +fn partially_staged_file_bare_with_no_worktree_errors() { + let result = FixtureBuilder::new() + .bare(true) + .partially_staged_file("f.txt", "committed", "staged", "workdir") + .build(); + + assert!( + result.is_err(), + "bare fixture with no worktree has no working tree to partially stage into" + ); +} + +#[cfg(unix)] +#[test] +fn untracked_symlink_is_visible_via_lstat_even_when_dangling( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .untracked_symlink("broken-link", "nonexistent-target") + .build()?; + + let dir = fixture.cwd()?; + let link_path = dir.path().join("broken-link"); + assert!( + link_path.symlink_metadata().is_ok(), + "lstat should see the dangling symlink" + ); + assert!( + !link_path.exists(), + "Path::exists follows the link and should report false for a dangling target" + ); + + Ok(()) +} diff --git a/git-workon-review/src/file_ops.rs b/git-workon-review/src/file_ops.rs new file mode 100644 index 0000000..0f7ef37 --- /dev/null +++ b/git-workon-review/src/file_ops.rs @@ -0,0 +1,76 @@ +//! Whole-file operations (trap 3): the staging verbs a hunk patch cannot express, because a +//! hunk patch always has BOTH a pre-image and a post-image to diff between. Creations, +//! deletions, and untracked files each have only one side — synthesizing a hunk patch for them +//! either has no preimage to apply against (untracked: git rejects it) or stages an EMPTY BLOB +//! instead of removing the file (deleted: git happily accepts a patch that deletes every line +//! of a tracked file, but that isn't the same operation as removing the index entry). `ops.rs` +//! routes those statuses here instead of through `synthesis`/`apply`. + +use std::path::Path; + +use git2::Repository; + +use crate::error::ApplyError; + +/// Stage `path`: `index.add_path` when the working-tree copy exists (covers Added, Untracked, +/// and Modified — an ordinary content update), `index.remove_path` when it doesn't (a real +/// deletion: `git rm`'s effect, not a hunk patch that would stage an empty blob). +/// +/// The choice is made by checking the working tree on disk, not by trusting a `FileStatus` +/// passed in by the caller — the two can only usefully agree once the check runs, so the check +/// is the source of truth (trap 3's core fix). +/// +/// The presence check uses `symlink_metadata` (lstat), NOT `Path::exists` (which follows +/// symlinks and reports `false` for a broken one). An untracked BROKEN symlink is still a real +/// working-tree entry that `git add` stages (as the link text, like any other symlink) — +/// `Path::exists` would silently take the `remove_path` branch instead, a no-op that leaves the +/// symlink unstaged with no error. +pub fn stage_file(repo: &Repository, path: &str) -> Result<(), ApplyError> { + let workdir = repo + .workdir() + .expect("stage_file requires a repository with a working directory"); + let mut index = repo.index()?; + if workdir.join(path).symlink_metadata().is_ok() { + index.add_path(Path::new(path))?; + } else { + index.remove_path(Path::new(path))?; + } + index.write()?; + Ok(()) +} + +/// Unstage `path`: reset its index entry back to `HEAD`. `reset_default` handles the +/// staged-new-file case natively — when `path` has no `HEAD` entry, the index entry is removed +/// outright and the file becomes untracked again, exactly like `git reset HEAD -- path` on a +/// newly-added file. +pub fn unstage_file(repo: &Repository, path: &str) -> Result<(), ApplyError> { + let head = repo.head()?.peel(git2::ObjectType::Commit)?; + repo.reset_default(Some(&head), [path])?; + Ok(()) +} + +/// Discard `path`'s working-tree changes: check out the INDEX'S copy over the working tree +/// copy, without touching the index (`update_index(false)` — a discard must not also stage +/// anything). This is `git restore `'s effect, NOT `git checkout HEAD -- `'s: on a +/// partially staged file (`HEAD` = A, index = B, workdir = C), discarding must revert the +/// workdir to what's staged (B), not blow past it to HEAD (A) and silently wipe the staged +/// work. `checkout_head` restores from `HEAD` and was wrong for exactly this reason — it's only +/// correct by coincidence when nothing is staged (index == HEAD). +pub fn discard_file(repo: &Repository, path: &str) -> Result<(), ApplyError> { + let mut opts = git2::build::CheckoutBuilder::new(); + opts.path(path).force().update_index(false); + repo.checkout_index(None, Some(&mut opts))?; + Ok(()) +} + +/// Discard an untracked file: there is no `HEAD`/index copy to check out, so "discard" means +/// deleting the working-tree file outright. +pub fn clean_untracked(repo: &Repository, path: &str) -> Result<(), ApplyError> { + let workdir = repo + .workdir() + .expect("clean_untracked requires a repository with a working directory"); + std::fs::remove_file(workdir.join(path)).map_err(|source| ApplyError::Io { + path: path.to_string(), + source, + }) +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 1a402b3..70ec4da 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -7,12 +7,14 @@ //! ## Status //! //! M2: the diff model ([`model`]), its acquisition from [`workon::Changeset`]s -//! ([`acquire`]), whole-hunk patch synthesis ([`synthesis`]), and the apply chokepoint -//! ([`apply`]) exist; line-precise synthesis, file ops, staging, and refresh land in later M2 -//! changesets. +//! ([`acquire`]), patch synthesis ([`synthesis`]), the apply chokepoint ([`apply`]), whole-file +//! ops ([`file_ops`]), and the patch-vs-file-op routing layer ([`ops`]) exist; staging and +//! refresh land in later M2 changesets. pub mod acquire; pub mod apply; pub mod error; +pub mod file_ops; pub mod model; +pub mod ops; pub mod synthesis; diff --git a/git-workon-review/src/ops.rs b/git-workon-review/src/ops.rs new file mode 100644 index 0000000..60d952e --- /dev/null +++ b/git-workon-review/src/ops.rs @@ -0,0 +1,109 @@ +//! Routing: the ONE place (per the M2 design decision) that decides, for a given +//! [`FileChange`], whether a staging verb goes through the patch-synthesis-and-apply path +//! (`synthesis.rs`/`apply.rs`) or the whole-file path (`file_ops.rs`). The TUI (M4) calls only +//! these three functions — it never picks a path itself. +//! +//! ## The routing table (trap 3) +//! +//! - [`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 +//! `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. + +use git2::Repository; + +use crate::apply::{Applier, StageVerb}; +use crate::error::ReviewError; +use crate::file_ops; +use crate::model::{FileChange, FileStatus}; +use crate::synthesis::{partial_hunk_patch, whole_hunk_patch, LineSelection}; + +/// Whether `file`'s status/binary-ness can be expressed as a two-sided hunk patch (Modified, +/// Renamed, or Copied, and not binary) — the routing predicate shared by `apply_hunk` and the +/// doc comments above. +fn is_hunk_patchable(file: &FileChange) -> bool { + !file.is_binary + && matches!( + file.status, + FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied + ) +} + +/// 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 +/// file-level op for `verb` — a hunk of an Added/Deleted/Untracked/Unmerged or binary file IS +/// the whole file, so there's nothing hunk-specific left to do. +pub fn apply_hunk( + repo: &Repository, + applier: &dyn Applier, + file: &FileChange, + hunk_idx: usize, + verb: StageVerb, +) -> Result<(), ReviewError> { + if is_hunk_patchable(file) { + let patch = whole_hunk_patch(file, hunk_idx)?; + let (_, dest, dir) = verb.plan(); + applier.apply(repo, &patch, dest, dir)?; + Ok(()) + } else { + apply_file(repo, file, verb) + } +} + +/// Apply `verb` to a line-precise selection of `file`'s hunk at `hunk_idx`. +/// +/// Unlike `apply_hunk`, this never falls back to a file-level op: line selection on a status a +/// hunk patch can't express (or a binary file) is a REFUSAL (trap 3), not a silent widening to +/// "the whole file." `partial_hunk_patch` already carries that refusal +/// ([`crate::error::SynthesisError::LineSelectionUnsupported`] / +/// [`crate::error::SynthesisError::BinaryFile`]), so calling it unconditionally and propagating +/// its `Result` is both the simplest routing and the correct one. +pub fn apply_lines( + repo: &Repository, + applier: &dyn Applier, + file: &FileChange, + hunk_idx: usize, + sel: &LineSelection, + verb: StageVerb, +) -> Result<(), ReviewError> { + let (base, dest, dir) = verb.plan(); + let patch = partial_hunk_patch(file, hunk_idx, sel, base)?; + applier.apply(repo, &patch, dest, dir)?; + Ok(()) +} + +/// Apply `verb` to the WHOLE of `file`, unconditionally via `file_ops.rs` — no synthesis +/// involved. This is also `apply_hunk`'s fallback for statuses a hunk patch can't express. +/// +/// `Discard` on an [`FileStatus::Untracked`] file is the one verb/status pair with no `HEAD` +/// copy to check out: [`file_ops::discard_file`]'s `checkout_head` has nothing to restore, so +/// "discard" instead means deleting the working-tree file outright +/// ([`file_ops::clean_untracked`]). +pub fn apply_file( + repo: &Repository, + file: &FileChange, + verb: StageVerb, +) -> Result<(), ReviewError> { + match verb { + StageVerb::Stage => file_ops::stage_file(repo, &file.path)?, + StageVerb::Unstage => file_ops::unstage_file(repo, &file.path)?, + StageVerb::Discard => { + if file.status == FileStatus::Untracked { + file_ops::clean_untracked(repo, &file.path)? + } else { + file_ops::discard_file(repo, &file.path)? + } + } + } + Ok(()) +} diff --git a/git-workon-review/tests/file_ops.rs b/git-workon-review/tests/file_ops.rs new file mode 100644 index 0000000..152c5a8 --- /dev/null +++ b/git-workon-review/tests/file_ops.rs @@ -0,0 +1,410 @@ +//! Trap 3 (whole-file ops): tripwires proving the naive hunk-patch shapes for +//! deletion/untracked files misbehave (empty-blob-stage / rejection), then the routed +//! `ops.rs`/`file_ops.rs` behavior that exists to route around them. +//! +//! Fixtures pin `core.autocrlf=false` so index/workdir byte assertions are platform-stable +//! (plan risk #6). + +use git_workon_fixture::prelude::*; +use workon_review::acquire::diff_uncommitted; +use workon_review::apply::{Applier, ApplyDestination, ApplyDirection, CliApplier, StageVerb}; +use workon_review::error::{ReviewError, SynthesisError}; +use workon_review::model::LineKind; +use workon_review::ops::{apply_file, apply_hunk, apply_lines}; +use workon_review::synthesis::{LineSelection, PatchHunk, PatchLine, PatchText}; + +/// 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 +/// `whole_hunk_patch` would produce if it didn't refuse `FileStatus::Deleted` — there's no live +/// way to ask the real synthesis path for it, so it's reconstructed by hand, mirroring +/// `tests/line_synthesis.rs`'s `naive_unspliced_patch` pattern. +fn naive_deletion_hunk_patch(path: &str, committed_content: &str) -> PatchText { + let lines: Vec = committed_content + .lines() + .map(|line| PatchLine { + kind: LineKind::Deletion, + content: format!("{line}\n").into_bytes(), + missing_newline: false, + }) + .collect(); + let count = lines.len() as u32; + PatchText { + old_path: Some(path.to_string()), + new_path: Some(path.to_string()), + old_mode: 0o100644, + new_mode: 0o100644, + hunks: vec![PatchHunk { + old_start: 1, + old_count: count, + new_start: 0, + new_count: 0, + header: format!("@@ -1,{count} +0,0 @@\n").into_bytes(), + lines, + }], + } +} + +/// 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 +/// bug `ops.rs`'s routing to `file_ops::stage_file` exists to prevent (trap 3). Verified +/// directly against `CliApplier` (the oracle), bypassing `ops.rs`/`synthesis.rs` entirely, +/// since `whole_hunk_patch` already refuses `FileStatus::Deleted` and can't produce this patch +/// itself. +#[test] +fn naive_hunk_stage_of_deletion_stages_empty_blob() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .deleted_file("gone.txt", "content\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let patch = naive_deletion_hunk_patch("gone.txt", "content\n"); + let result = CliApplier.apply( + repo, + &patch, + ApplyDestination::Index, + ApplyDirection::Forward, + ); + + assert!( + result.is_ok(), + "expected git apply --cached to accept the naive deletion hunk, got {result:?}" + ); + 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. +#[test] +fn naive_hunk_stage_of_untracked_errors() { + 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 patch = naive_untracked_hunk_patch("new.txt", "hello\n"); + let result = CliApplier.apply( + repo, + &patch, + ApplyDestination::Index, + ApplyDirection::Forward, + ); + + assert!( + result.is_err(), + "expected git apply --cached to reject the naive untracked hunk, got {result:?}" + ); +} + +#[test] +fn apply_lines_on_deleted_file_refuses() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .deleted_file("gone.txt", "content\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 result = apply_lines(repo, &CliApplier, file, 0, &sel, StageVerb::Stage); + + assert!( + matches!( + result, + Err(ReviewError::Synthesis( + SynthesisError::LineSelectionUnsupported { .. } + )) + ), + "expected LineSelectionUnsupported, got {result:?}" + ); +} + +#[test] +fn apply_lines_on_untracked_file_refuses() { + 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 diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + let sel = LineSelection::default(); + let result = apply_lines(repo, &CliApplier, file, 0, &sel, StageVerb::Stage); + + assert!( + matches!( + result, + Err(ReviewError::Synthesis( + SynthesisError::LineSelectionUnsupported { .. } + )) + ), + "expected LineSelectionUnsupported, got {result:?}" + ); +} + +#[test] +fn apply_lines_on_added_file_refuses() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("added.txt", "hello\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); + + assert!( + matches!( + result, + Err(ReviewError::Synthesis( + SynthesisError::LineSelectionUnsupported { .. } + )) + ), + "expected LineSelectionUnsupported, got {result:?}" + ); +} + +#[test] +fn apply_file_stage_on_deleted_file_stages_the_deletion() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .deleted_file("gone.txt", "content\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_file(repo, file, StageVerb::Stage).expect("apply_file"); + + fixture.assert(predicate::repo::has_staged_deletion("gone.txt")); +} + +#[test] +fn apply_file_stage_on_untracked_file_stages_its_content() { + 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 diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Stage).expect("apply_file"); + + fixture.assert(predicate::repo::has_staged_file("new.txt")); + fixture.assert(predicate::repo::index_blob_equals( + "new.txt", + b"hello\n".to_vec(), + )); +} + +#[test] +fn apply_file_discard_on_untracked_file_removes_it() { + 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 diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Discard).expect("apply_file"); + + assert!(!repo.workdir().unwrap().join("new.txt").exists()); +} + +#[test] +fn apply_file_unstage_on_staged_new_file_becomes_untracked_again() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("added.txt", "hello\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]; + apply_file(repo, file, StageVerb::Unstage).expect("apply_file"); + + fixture.assert(predicate::repo::has_untracked_file("added.txt")); + let mut index = repo.index().expect("index"); + index.read(true).expect("index reload"); + assert!( + index + .get_path(std::path::Path::new("added.txt"), 0) + .is_none(), + "expected no index entry for added.txt after unstage" + ); +} + +#[test] +fn apply_file_discard_on_tracked_modified_file_reverts_content() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nCHANGED\nline3\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_file(repo, file, StageVerb::Discard).expect("apply_file"); + + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"line1\nline2\nline3\n".to_vec(), + )); +} + +#[test] +fn apply_hunk_on_binary_modified_file_routes_to_file_level_stage() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .bare(true) + .worktree("main") + .build() + .expect("fixture build"); + + fixture + .commit("main") + .file_bytes("bin.dat", vec![0u8, 1, 2, 3, b'a', 0u8]) + .create("add binary") + .expect("commit binary"); + + let repo = fixture.repo().expect("repo"); + let new_bytes = vec![0u8, 9, 9, 9, b'z', 0u8]; + std::fs::write(repo.workdir().unwrap().join("bin.dat"), &new_bytes) + .expect("overwrite binary file"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + assert!(file.is_binary, "expected the modified file to be binary"); + + apply_hunk(repo, &CliApplier, file, 0, StageVerb::Stage).expect("apply_hunk"); + + fixture.assert(predicate::repo::index_blob_equals("bin.dat", new_bytes)); +} + +/// Regression for the `discard_file` bug: on a PARTIALLY staged file (`HEAD` = "committed", +/// index = "staged", workdir = "workdir" — three distinct states), discarding must revert the +/// workdir to the INDEX's content ("staged"), matching `git restore ` — NOT blow past it +/// to `HEAD`'s content ("committed"), which is what the old `checkout_head`-based +/// implementation did, silently wiping staged work off disk. +#[test] +fn apply_file_discard_on_partially_staged_file_reverts_to_index_not_head() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .partially_staged_file("f.txt", "committed\n", "staged\n", "workdir\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_file(repo, file, StageVerb::Discard).expect("apply_file"); + + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"staged\n".to_vec(), + )); + // The index itself must be untouched by a discard. + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"staged\n".to_vec(), + )); +} + +/// Regression for the `stage_file` bug: an untracked BROKEN symlink is a real working-tree +/// entry (`git add` stages it, storing the link text as the blob, exactly like any other +/// symlink) — but the old `Path::exists()` check follows the link, sees nothing at the +/// (nonexistent) target, and silently takes the `remove_path` branch instead: a no-op that +/// returns `Ok` without staging anything. +#[cfg(unix)] +#[test] +fn apply_file_stage_on_untracked_broken_symlink_stages_it() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_symlink("broken-link", "nonexistent-target") + .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_file(repo, file, StageVerb::Stage).expect("apply_file"); + + let mut index = repo.index().expect("index"); + index.read(true).expect("index reload"); + assert!( + index + .get_path(std::path::Path::new("broken-link"), 0) + .is_some(), + "expected an index entry for the staged broken symlink" + ); +} + +#[test] +fn apply_hunk_on_modified_text_file_passes_through_to_whole_hunk_stage() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "line1\nline2\nline3\n", "line1\nCHANGED\nline3\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( + "f.txt", + b"line1\nCHANGED\nline3\n".to_vec(), + )); +} From ba0e1214abc2fd4c140ca42f65c36d7f2ffe5c70 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 01:10:46 -0400 Subject: [PATCH 013/203] feat(review): add FIFO staging queue with live-index direction --- git-workon-review/src/lib.rs | 6 +- git-workon-review/src/queue.rs | 509 +++++++++++++++++++++++++++++++++ 2 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 git-workon-review/src/queue.rs diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 70ec4da..4ecf811 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -8,8 +8,9 @@ //! //! M2: the diff model ([`model`]), its acquisition from [`workon::Changeset`]s //! ([`acquire`]), patch synthesis ([`synthesis`]), the apply chokepoint ([`apply`]), whole-file -//! ops ([`file_ops`]), and the patch-vs-file-op routing layer ([`ops`]) exist; staging and -//! refresh land in later M2 changesets. +//! ops ([`file_ops`]), the patch-vs-file-op routing layer ([`ops`]), and the FIFO staging queue +//! ([`queue`]) exist; the refresh coordinator and the round-trip verdict corpus land in later +//! M2 changesets. pub mod acquire; pub mod apply; @@ -17,4 +18,5 @@ pub mod error; pub mod file_ops; pub mod model; pub mod ops; +pub mod queue; pub mod synthesis; diff --git a/git-workon-review/src/queue.rs b/git-workon-review/src/queue.rs new file mode 100644 index 0000000..900eb7e --- /dev/null +++ b/git-workon-review/src/queue.rs @@ -0,0 +1,509 @@ +//! FIFO staging queue (trap 4): callers enqueue [`StagingOp`]s, [`StagingQueue::pump`] runs the +//! head op synchronously against the live index. Runtime-agnostic per the M2 design decision — +//! no tokio, no owned thread; the caller (M4's TUI event loop) decides when to pump. +//! +//! ## The stale-snapshot trap +//! +//! An op MUST resolve its direction (stage vs. unstage, etc.) by querying the LIVE index +//! INSIDE `run` — via [`path_has_staged_changes`] or equivalent — never from a snapshot taken +//! at enqueue time. Two ops enqueued back-to-back for the same path (e.g. a user double-toggling +//! a file before the first op has run) both see whatever the index looks like when THEY run, not +//! when they were queued; a snapshot-at-enqueue implementation would have both ops decide the +//! same direction and the second would silently no-op instead of round-tripping the toggle. +//! +//! ## In-flight accounting +//! +//! [`OpContext::queue_len`] includes the op currently running — `pump` computes it from the +//! queue BEFORE popping the head op, and only removes the op once `run` returns (success, +//! failure, or panic). This lets an op (or a refresh gate reading [`StagingQueue::len`]) see +//! "at least one more op is in flight" for its own duration, not just for the ops still waiting +//! behind it. See `refresh.rs` for the consumer: `RefreshCoordinator::note_index_event` refuses +//! to schedule a refresh while this count is nonzero. + +use std::collections::VecDeque; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::time::Duration; + +use git2::{Repository, StatusOptions}; + +use crate::apply::Applier; +use crate::error::ApplyError; + +/// Identifies a queued operation, assigned in enqueue order. +pub type OpId = u64; + +/// What a running [`StagingOp`] needs to do its work and to make its own live-index checks. +pub struct OpContext<'a> { + pub repo: &'a Repository, + pub applier: &'a dyn Applier, + /// Number of ops in the queue, INCLUDING the one currently running. + pub queue_len: usize, +} + +/// Whether `path` currently has any staged (index-side) changes, resolved against the LIVE +/// index at call time — the direction-resolution primitive ops must call from inside `run` +/// (never at enqueue time; see the module doc's stale-snapshot trap). +pub fn path_has_staged_changes(repo: &Repository, path: &str) -> Result { + let mut opts = StatusOptions::new(); + opts.pathspec(path); + opts.include_untracked(true); + let statuses = repo.statuses(Some(&mut opts))?; + let index_bits = git2::Status::INDEX_NEW + | git2::Status::INDEX_MODIFIED + | git2::Status::INDEX_DELETED + | git2::Status::INDEX_RENAMED + | git2::Status::INDEX_TYPECHANGE; + Ok(statuses + .iter() + .any(|entry| entry.status().intersects(index_bits))) +} + +/// A single queued staging action. Implementations resolve their own direction from the live +/// index inside `run` (see module docs) rather than trusting anything decided at enqueue time. +pub trait StagingOp: Send { + fn run(&mut self, ctx: &OpContext<'_>) -> Result<(), ApplyError>; +} + +/// The result of running one queued op. +#[derive(Debug)] +pub enum OpOutcome { + Completed(OpId), + Failed(OpId, ApplyError), + Panicked(OpId), +} + +/// Injectable sleep for retry backoff — a type alias keeps `StagingQueue`'s field type simple +/// enough for clippy's `type_complexity` lint (a bare `Box` field would +/// otherwise read as a false positive candidate once combined with the rest of the struct). +type SleepFn = Box; + +/// Runtime-agnostic FIFO queue of staging operations (trap 4). Only the head op ever runs; +/// [`StagingQueue::pump`] runs it synchronously to completion (including its one retry, if +/// index-lock contention is hit) before removing it. +pub struct StagingQueue { + queue: VecDeque<(OpId, Box)>, + next_id: OpId, + retry_delay: Duration, + sleep: SleepFn, +} + +impl Default for StagingQueue { + fn default() -> Self { + Self::new() + } +} + +impl StagingQueue { + /// A queue with the real 100ms retry delay and `std::thread::sleep`. + pub fn new() -> Self { + Self::with_retry(Duration::from_millis(100), std::thread::sleep) + } + + /// A queue with an injectable retry delay and sleep function — tests pass `Duration::ZERO` + /// and a counting closure so the retry-once policy can be asserted without actually + /// sleeping. + pub fn with_retry(delay: Duration, sleep: impl FnMut(Duration) + 'static) -> Self { + Self { + queue: VecDeque::new(), + next_id: 0, + retry_delay: delay, + sleep: Box::new(sleep), + } + } + + /// Enqueue `op` at the tail, returning its assigned id. + pub fn enqueue(&mut self, op: impl StagingOp + 'static) -> OpId { + let id = self.next_id; + self.next_id += 1; + self.queue.push_back((id, Box::new(op))); + id + } + + /// Run the head op synchronously against `repo`/`applier`. Returns `None` if the queue is + /// empty. + /// + /// The op is removed from the queue only AFTER it finishes — `queue_len` is computed from + /// the queue's current length (including the head op itself) before the op runs, so an op + /// can see "how many ops, including me, are outstanding" via [`OpContext::queue_len`]. + /// + /// On a lock-contention error ([`crate::apply::is_lock_contention`]), the op is retried + /// exactly once after `sleep(retry_delay)`; a second lock failure yields + /// `Failed(id, ApplyError::IndexLocked { attempts: 2 })` rather than propagating the + /// original error, since by that point the queue has given up and the notable fact is the + /// retry count, not which particular lock error surfaced. Non-lock errors fail immediately, + /// no retry. + pub fn pump(&mut self, repo: &Repository, applier: &dyn Applier) -> Option { + let queue_len = self.queue.len(); + let (id, mut op) = self.queue.pop_front()?; + + let ctx = OpContext { + repo, + applier, + queue_len, + }; + + // SAFETY/soundness note (plan risk #9): `&Repository` isn't `UnwindSafe`, so `run` + // can't be called under `catch_unwind` without asserting it. This is sound because the + // op (and the `ctx` borrowing `repo`) is discarded immediately after a panic is caught + // — nothing observes `op`'s or `repo`'s state through a broken invariant afterward; the + // queue only ever looks at its own `VecDeque`, which is untouched by the panic. + let result = catch_unwind(AssertUnwindSafe(|| op.run(&ctx))); + + let outcome = match result { + Ok(Ok(())) => OpOutcome::Completed(id), + Ok(Err(e)) if crate::apply::is_lock_contention(&e) => { + (self.sleep)(self.retry_delay); + let retry_ctx = OpContext { + repo, + applier, + queue_len, + }; + match catch_unwind(AssertUnwindSafe(|| op.run(&retry_ctx))) { + Ok(Ok(())) => OpOutcome::Completed(id), + Ok(Err(_)) => OpOutcome::Failed(id, ApplyError::IndexLocked { attempts: 2 }), + Err(_) => OpOutcome::Panicked(id), + } + } + Ok(Err(e)) => OpOutcome::Failed(id, e), + Err(_) => OpOutcome::Panicked(id), + }; + + Some(outcome) + } + + /// Pump until the queue is empty, collecting each op's outcome in order. + pub fn drain(&mut self, repo: &Repository, applier: &dyn Applier) -> Vec { + let mut outcomes = Vec::new(); + while let Some(outcome) = self.pump(repo, applier) { + outcomes.push(outcome); + } + outcomes + } + + pub fn len(&self) -> usize { + self.queue.len() + } + + pub fn is_empty(&self) -> bool { + self.queue.is_empty() + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use git_workon_fixture::prelude::*; + + use super::*; + use crate::apply::CliApplier; + use crate::file_ops::unstage_file; + + /// A fake op that records its id into a shared log on run and always succeeds. Uses + /// `Arc>` (not `Rc>`) because [`StagingOp`] requires `Send`. + struct RecordingOp { + id_slot: OpId, + log: Arc>>, + } + + impl StagingOp for RecordingOp { + fn run(&mut self, _ctx: &OpContext<'_>) -> Result<(), ApplyError> { + self.log.lock().unwrap().push(self.id_slot); + Ok(()) + } + } + + /// An op that asserts `ctx.queue_len` equals an expected value the first time it runs. + struct AssertQueueLenOp { + expected: usize, + seen: Arc>>, + } + + impl StagingOp for AssertQueueLenOp { + fn run(&mut self, ctx: &OpContext<'_>) -> Result<(), ApplyError> { + *self.seen.lock().unwrap() = Some(ctx.queue_len); + assert_eq!( + ctx.queue_len, self.expected, + "op did not see itself counted in queue_len while running" + ); + Ok(()) + } + } + + fn lock_error() -> ApplyError { + ApplyError::Git(git2::Error::new( + git2::ErrorCode::Locked, + git2::ErrorClass::Index, + "locked", + )) + } + + /// A fake op that fails with a lock error for its first `attempts_to_fail` calls, then + /// succeeds. + struct FlakyLockOp { + attempts_to_fail: u32, + calls: u32, + } + + impl StagingOp for FlakyLockOp { + fn run(&mut self, _ctx: &OpContext<'_>) -> Result<(), ApplyError> { + self.calls += 1; + if self.calls <= self.attempts_to_fail { + Err(lock_error()) + } else { + Ok(()) + } + } + } + + /// A fake op that always fails with a non-lock error. + struct AlwaysNonLockErrorOp; + + impl StagingOp for AlwaysNonLockErrorOp { + fn run(&mut self, _ctx: &OpContext<'_>) -> Result<(), ApplyError> { + Err(ApplyError::GitSpawn(std::io::Error::other("boom"))) + } + } + + /// An op whose `run` panics unconditionally. + struct PanickingOp; + + impl StagingOp for PanickingOp { + fn run(&mut self, _ctx: &OpContext<'_>) -> Result<(), ApplyError> { + panic!("staging op exploded"); + } + } + + /// Toggles staged/unstaged state of `path` by resolving direction from the LIVE index + /// inside `run` — proves ops must not cache the direction at enqueue time (trap 4's + /// stale-snapshot bug). + struct ToggleOp { + path: &'static str, + } + + impl StagingOp for ToggleOp { + fn run(&mut self, ctx: &OpContext<'_>) -> Result<(), ApplyError> { + if path_has_staged_changes(ctx.repo, self.path)? { + unstage_file(ctx.repo, self.path) + } else { + let mut index = ctx.repo.index()?; + index.add_path(std::path::Path::new(self.path))?; + index.write()?; + Ok(()) + } + } + } + + fn fresh_queue() -> (StagingQueue, Arc>) { + let sleep_calls = Arc::new(Mutex::new(0u32)); + let counter = Arc::clone(&sleep_calls); + let queue = StagingQueue::with_retry(Duration::ZERO, move |_| { + *counter.lock().unwrap() += 1; + }); + (queue, sleep_calls) + } + + #[test] + fn fifo_order_preserved_through_drain() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let log = Arc::new(Mutex::new(Vec::new())); + let (mut queue, _sleep_calls) = fresh_queue(); + let a = queue.enqueue(RecordingOp { + id_slot: 0, + log: Arc::clone(&log), + }); + let b = queue.enqueue(RecordingOp { + id_slot: 1, + log: Arc::clone(&log), + }); + let c = queue.enqueue(RecordingOp { + id_slot: 2, + log: Arc::clone(&log), + }); + + let outcomes = queue.drain(repo, &CliApplier); + + assert_eq!(*log.lock().unwrap(), vec![a, b, c]); + assert!(matches!(outcomes[0], OpOutcome::Completed(id) if id == a)); + assert!(matches!(outcomes[1], OpOutcome::Completed(id) if id == b)); + assert!(matches!(outcomes[2], OpOutcome::Completed(id) if id == c)); + } + + #[test] + fn head_op_sees_itself_counted_in_queue_len() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let seen = Arc::new(Mutex::new(None)); + let (mut queue, _sleep_calls) = fresh_queue(); + // Two ops queued: the head op should see queue_len == 2 (itself + the one behind it). + queue.enqueue(AssertQueueLenOp { + expected: 2, + seen: Arc::clone(&seen), + }); + queue.enqueue(RecordingOp { + id_slot: 99, + log: Arc::new(Mutex::new(Vec::new())), + }); + + let outcome = queue.pump(repo, &CliApplier).expect("pump"); + assert!(matches!(outcome, OpOutcome::Completed(_))); + assert_eq!(*seen.lock().unwrap(), Some(2)); + } + + /// Two toggles queued for the same path, starting unstaged: if an implementation resolved + /// "stage" once at enqueue time and reused it, both ops would stage, leaving the file + /// staged. Because `ToggleOp::run` calls `path_has_staged_changes` against the LIVE index + /// each time it actually runs, the first toggle stages it and the second (seeing the + /// now-staged index) unstages it back — the file round-trips to unstaged, exactly the + /// trap-4 stale-snapshot bug this test guards against. + #[test] + fn two_queued_toggles_resolve_from_live_index_and_round_trip() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("f.txt", "hello\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let (mut queue, _sleep_calls) = fresh_queue(); + queue.enqueue(ToggleOp { path: "f.txt" }); + queue.enqueue(ToggleOp { path: "f.txt" }); + + let outcomes = queue.drain(repo, &CliApplier); + + assert!(outcomes + .iter() + .all(|o| matches!(o, OpOutcome::Completed(_)))); + fixture.assert(predicate::repo::has_untracked_file("f.txt")); + } + + #[test] + fn lock_failure_then_success_retries_exactly_once() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let (mut queue, sleep_calls) = fresh_queue(); + queue.enqueue(FlakyLockOp { + attempts_to_fail: 1, + calls: 0, + }); + + let outcome = queue.pump(repo, &CliApplier).expect("pump"); + assert!(matches!(outcome, OpOutcome::Completed(_))); + assert_eq!( + *sleep_calls.lock().unwrap(), + 1, + "expected exactly one retry sleep" + ); + } + + #[test] + fn lock_failure_twice_fails_with_index_locked_after_two_attempts() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let (mut queue, sleep_calls) = fresh_queue(); + queue.enqueue(FlakyLockOp { + attempts_to_fail: 2, + calls: 0, + }); + + let outcome = queue.pump(repo, &CliApplier).expect("pump"); + match outcome { + OpOutcome::Failed(_, ApplyError::IndexLocked { attempts }) => { + assert_eq!(attempts, 2); + } + other => panic!("expected Failed(IndexLocked{{attempts: 2}}), got {other:?}"), + } + assert_eq!( + *sleep_calls.lock().unwrap(), + 1, + "expected only one retry sleep, not one per attempt" + ); + } + + #[test] + fn non_lock_error_fails_immediately_without_retry() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let (mut queue, sleep_calls) = fresh_queue(); + queue.enqueue(AlwaysNonLockErrorOp); + + let outcome = queue.pump(repo, &CliApplier).expect("pump"); + assert!(matches!( + outcome, + OpOutcome::Failed(_, ApplyError::GitSpawn(_)) + )); + assert_eq!( + *sleep_calls.lock().unwrap(), + 0, + "non-lock errors must not retry" + ); + } + + #[test] + fn panicking_op_is_contained_and_queue_remains_usable() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let log = Arc::new(Mutex::new(Vec::new())); + let (mut queue, _sleep_calls) = fresh_queue(); + queue.enqueue(PanickingOp); + // `id_slot` need not match the queue-assigned `OpId` (that's only known once + // `enqueue` returns) — the log just needs one entry to prove this op ran. + let following = queue.enqueue(RecordingOp { + id_slot: 1, + log: Arc::clone(&log), + }); + + let prev_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let first = queue.pump(repo, &CliApplier).expect("pump"); + std::panic::set_hook(prev_hook); + + assert!(matches!(first, OpOutcome::Panicked(_))); + + let second = queue.pump(repo, &CliApplier).expect("pump"); + assert!(matches!(second, OpOutcome::Completed(id) if id == following)); + assert_eq!( + log.lock().unwrap().len(), + 1, + "expected the following op to run once" + ); + } +} From a066f8e301ca8e034b4b766f0c01fbd4bdcae4ed Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 01:13:08 -0400 Subject: [PATCH 014/203] feat(review): add refresh coordinator with generation supersede --- git-workon-review/src/lib.rs | 7 +- git-workon-review/src/refresh.rs | 232 +++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 git-workon-review/src/refresh.rs diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 4ecf811..f3aa6f6 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -8,9 +8,9 @@ //! //! M2: the diff model ([`model`]), its acquisition from [`workon::Changeset`]s //! ([`acquire`]), patch synthesis ([`synthesis`]), the apply chokepoint ([`apply`]), whole-file -//! ops ([`file_ops`]), the patch-vs-file-op routing layer ([`ops`]), and the FIFO staging queue -//! ([`queue`]) exist; the refresh coordinator and the round-trip verdict corpus land in later -//! M2 changesets. +//! ops ([`file_ops`]), the patch-vs-file-op routing layer ([`ops`]), the FIFO staging queue +//! ([`queue`]), and the refresh generation coordinator ([`refresh`]) exist; the round-trip +//! verdict corpus lands in the next M2 changeset. pub mod acquire; pub mod apply; @@ -19,4 +19,5 @@ pub mod file_ops; pub mod model; pub mod ops; pub mod queue; +pub mod refresh; pub mod synthesis; diff --git a/git-workon-review/src/refresh.rs b/git-workon-review/src/refresh.rs new file mode 100644 index 0000000..52e645e --- /dev/null +++ b/git-workon-review/src/refresh.rs @@ -0,0 +1,232 @@ +//! Refresh generation/livelock coordination (trap 5): a pure state machine tracking which +//! re-diff is the latest one requested, so a slow refresh that finishes after a newer one has +//! already started doesn't clobber fresher results. +//! +//! ## Interlock with `queue.rs` +//! +//! [`RefreshCoordinator::note_index_event`] refuses to schedule a refresh while +//! [`crate::queue::StagingQueue::len`] is nonzero (passed in as `staging_queue_len`) — a +//! refresh only makes sense once the queue has drained, since an in-flight staging op is about +//! to change the index again anyway (trap 4/5 interlock). +//! +//! ## M4 wiring intent (forward-looking; not built here) +//! +//! A filesystem watcher will call [`RefreshCoordinator::note_index_event`] whenever `.git/index` +//! changes, using [`IndexSignature::read`] to build the signature. When it returns `true`, the +//! caller starts an async re-diff, calling [`RefreshCoordinator::begin`] before starting the +//! work and [`RefreshCoordinator::complete`] when it finishes. + +use std::io; +use std::path::Path; + +/// A cheap fingerprint of `.git/index`'s on-disk state (mtime + size), used to distinguish a +/// genuinely new index write from an echo of one this process just made itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IndexSignature { + pub mtime_sec: i64, + pub mtime_nsec: i64, + pub size: u64, +} + +impl IndexSignature { + /// Read the current signature of `/index`. M4 convenience for wiring a real + /// filesystem watcher; M2's tests use synthetic signatures (see `tests` below) since the + /// coordinator's logic never inspects the fields itself, only compares whole signatures. + pub fn read(git_dir: &Path) -> io::Result { + use std::os::unix::fs::MetadataExt; + + let metadata = std::fs::metadata(git_dir.join("index"))?; + Ok(IndexSignature { + mtime_sec: metadata.mtime(), + mtime_nsec: metadata.mtime_nsec(), + size: metadata.size(), + }) + } +} + +/// A ticket for one in-flight refresh, returned by [`RefreshCoordinator::begin`] and consumed by +/// [`RefreshCoordinator::complete`]. Carries the generation it was started at; fields are +/// private, only constructed by `begin`. +#[derive(Debug, Clone, Copy)] +pub struct RefreshTicket { + generation: u64, +} + +/// What a completing refresh should do with its result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Completion { + /// This was the latest refresh started — commit its result. + Commit, + /// A newer refresh has started since this one began — discard this result, a fresher one + /// is already on the way. + Superseded, +} + +/// Generation counter + last-seen index signature, implementing the trap-5 supersede/livelock +/// invariants. See the module docs for the `queue.rs` interlock and the M4 wiring intent. +pub struct RefreshCoordinator { + next_gen: u64, + latest_started: u64, + last_signature: Option, +} + +impl Default for RefreshCoordinator { + fn default() -> Self { + Self::new() + } +} + +impl RefreshCoordinator { + pub fn new() -> Self { + Self { + next_gen: 1, + latest_started: 0, + last_signature: None, + } + } + + /// Whether an index-change event at `sig` should schedule a new refresh. + /// + /// `false` if `staging_queue_len > 0` — a staging op is still in flight, so the index is + /// about to change again; wait for the queue to drain (the `queue.rs` interlock). `false` if + /// `sig` matches `last_signature` — this is the echo of a write this process already + /// accounted for (see `complete`'s doc comment for how that signature got recorded). `true` + /// otherwise: a genuinely new, unseen index state with no staging op in flight. + /// + /// This method deliberately does NOT update `last_signature` itself — only `complete` does. + /// If it recorded the signature here, a genuinely external change event would be "seen" and + /// suppressed the moment it arrived, before any refresh even ran to observe it; worse, the + /// signature that actually needs recording is the one a refresh completes with, at the + /// specific point trap 5 cares about (see `complete`), not the raw event that triggered the + /// refresh in the first place. Comparisons live here; recording lives in `complete`. + pub fn note_index_event(&mut self, sig: IndexSignature, staging_queue_len: usize) -> bool { + if staging_queue_len > 0 { + return false; + } + if self.last_signature == Some(sig) { + return false; + } + true + } + + /// Start a new refresh, returning a ticket carrying its generation. The generation becomes + /// `self`'s `latest_started`, so any ticket from an earlier `begin` call will be superseded + /// at `complete` time. + pub fn begin(&mut self) -> RefreshTicket { + let generation = self.next_gen; + self.next_gen += 1; + self.latest_started = generation; + RefreshTicket { generation } + } + + /// Complete a refresh. `sig_at_completion` is the index signature observed AT THE MOMENT the + /// refresh finished (not when it started) — the refresh's own diffing work may itself have + /// touched the index's stat cache, so the signature must be captured fresh here. + /// + /// INVARIANT (the livelock fix): `sig_at_completion` is recorded into `last_signature` + /// UNCONDITIONALLY, before the generation check decides `Commit` vs `Superseded` — including + /// on the LOSING (`Superseded`) path. If a losing completion skipped recording its signature, + /// the stat-cache rewrite its own diffing caused would arrive at the watcher as an + /// apparently-new, never-seen index event, `note_index_event` would say "schedule a + /// refresh" for it, and under a storm of staging changes each losing refresh's echo would + /// re-trigger another refresh forever. Recording unconditionally — even on the losing path — + /// means that specific echo is always exactly the "already seen" signature, so it gets + /// suppressed instead of livelocking the refresh loop. + pub fn complete( + &mut self, + ticket: RefreshTicket, + sig_at_completion: IndexSignature, + ) -> Completion { + self.last_signature = Some(sig_at_completion); + if ticket.generation == self.latest_started { + Completion::Commit + } else { + Completion::Superseded + } + } +} + +#[cfg(test)] +mod tests { + use git_workon_fixture::prelude::*; + + use super::*; + + fn sig(n: i64) -> IndexSignature { + IndexSignature { + mtime_sec: n, + mtime_nsec: 0, + size: n as u64, + } + } + + #[test] + fn single_begin_complete_pair_commits() { + let mut coordinator = RefreshCoordinator::new(); + let ticket = coordinator.begin(); + assert_eq!(coordinator.complete(ticket, sig(1)), Completion::Commit); + } + + #[test] + fn last_writer_wins_among_two_in_flight_refreshes() { + let mut coordinator = RefreshCoordinator::new(); + let a = coordinator.begin(); + let b = coordinator.begin(); + + assert_eq!(coordinator.complete(a, sig(1)), Completion::Superseded); + assert_eq!(coordinator.complete(b, sig(2)), Completion::Commit); + } + + /// THE LIVELOCK INVARIANT: a losing (`Superseded`) completion still records its signature, + /// so the echo of its own write is suppressed by a subsequent `note_index_event` rather than + /// re-triggering another refresh — see `complete`'s doc comment for the full story. + #[test] + fn losing_completion_still_records_signature_to_suppress_echo() { + let mut coordinator = RefreshCoordinator::new(); + let a = coordinator.begin(); + let b = coordinator.begin(); + + assert_eq!(coordinator.complete(a, sig(2)), Completion::Superseded); + assert!( + !coordinator.note_index_event(sig(2), 0), + "the losing completion's own write should be suppressed as an echo" + ); + + assert_eq!(coordinator.complete(b, sig(3)), Completion::Commit); + assert!( + !coordinator.note_index_event(sig(3), 0), + "the winning completion's own write should also be suppressed as an echo" + ); + assert!( + coordinator.note_index_event(sig(4), 0), + "a genuinely new external change must still schedule a refresh" + ); + } + + #[test] + fn queue_gate_blocks_refresh_until_drained() { + let mut coordinator = RefreshCoordinator::new(); + + assert!( + !coordinator.note_index_event(sig(5), 1), + "a nonzero staging queue must block scheduling, even for a new signature" + ); + assert!( + coordinator.note_index_event(sig(5), 0), + "the same signature should schedule once the queue has drained" + ); + } + + #[test] + fn index_signature_read_reads_a_real_index_file() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("f.txt", "hello\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let sig = IndexSignature::read(repo.path()).expect("read index signature"); + assert!(sig.size > 0, "expected a nonzero index file size"); + } +} From 7ca78e631b1b01bbef7bafb7a026127d41cfe5dc Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 17:51:37 -0400 Subject: [PATCH 015/203] fix(review): fix staging pathspec glob and retry error swallow --- git-workon-review/src/queue.rs | 79 +++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/git-workon-review/src/queue.rs b/git-workon-review/src/queue.rs index 900eb7e..fbed8bf 100644 --- a/git-workon-review/src/queue.rs +++ b/git-workon-review/src/queue.rs @@ -46,6 +46,10 @@ pub struct OpContext<'a> { pub fn path_has_staged_changes(repo: &Repository, path: &str) -> Result { let mut opts = StatusOptions::new(); opts.pathspec(path); + // Without this, `pathspec` treats `path` as a glob — a path containing glob metacharacters + // (e.g. `app/[slug]/page.tsx`) then never matches itself, and this always resolves "no + // staged changes" regardless of the real index state. + opts.disable_pathspec_match(true); opts.include_untracked(true); let statuses = repo.statuses(Some(&mut opts))?; let index_bits = git2::Status::INDEX_NEW @@ -160,7 +164,14 @@ impl StagingQueue { }; match catch_unwind(AssertUnwindSafe(|| op.run(&retry_ctx))) { Ok(Ok(())) => OpOutcome::Completed(id), - Ok(Err(_)) => OpOutcome::Failed(id, ApplyError::IndexLocked { attempts: 2 }), + // Only a SECOND lock-contention error collapses into the "gave up + // retrying" outcome — any other error on retry is its own distinct + // failure and must propagate as itself, not be swallowed under a + // misleading `IndexLocked` label. + Ok(Err(e)) if crate::apply::is_lock_contention(&e) => { + OpOutcome::Failed(id, ApplyError::IndexLocked { attempts: 2 }) + } + Ok(Err(e)) => OpOutcome::Failed(id, e), Err(_) => OpOutcome::Panicked(id), } } @@ -266,6 +277,24 @@ mod tests { } } + /// A fake op that fails with a lock error on its first call, then a DIFFERENT (non-lock) + /// error on every call after — the shape that catches the retry-arm bug: a second failure + /// that isn't itself a lock error must surface as itself, not get relabeled `IndexLocked`. + struct FlakyThenDifferentErrorOp { + calls: u32, + } + + impl StagingOp for FlakyThenDifferentErrorOp { + fn run(&mut self, _ctx: &OpContext<'_>) -> Result<(), ApplyError> { + self.calls += 1; + if self.calls == 1 { + Err(lock_error()) + } else { + Err(ApplyError::GitSpawn(std::io::Error::other("boom"))) + } + } + } + /// An op whose `run` panics unconditionally. struct PanickingOp; @@ -364,6 +393,25 @@ mod tests { assert_eq!(*seen.lock().unwrap(), Some(2)); } + /// Regression: `path_has_staged_changes` must match a literal path even when it contains + /// glob metacharacters (`[...]`) — without `disable_pathspec_match`, `StatusOptions::pathspec` + /// treats `path` as a glob, and a bracketed path like `app/[slug]/page.tsx` never matches + /// itself, so this always resolved "no staged changes" regardless of the real index state. + #[test] + fn path_has_staged_changes_matches_bracketed_path() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("app/[slug]/page.tsx", "content\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + assert!( + path_has_staged_changes(repo, "app/[slug]/page.tsx").expect("path_has_staged_changes"), + "expected the bracketed path's staged entry to be found" + ); + } + /// Two toggles queued for the same path, starting unstaged: if an implementation resolved /// "stage" once at enqueue time and reused it, both ops would stage, leaving the file /// staged. Because `ToggleOp::run` calls `path_has_staged_changes` against the LIVE index @@ -446,6 +494,35 @@ mod tests { ); } + /// Regression: a lock error on the first attempt followed by a DIFFERENT (non-lock) error + /// on the retry must surface as that second error, not get collapsed into + /// `IndexLocked{attempts: 2}` — the retry arm previously matched `Ok(Err(_))` unconditionally + /// on the second attempt, swallowing whatever error actually occurred. + #[test] + fn lock_failure_then_different_error_propagates_that_error() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .worktree("main") + .bare(true) + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let (mut queue, sleep_calls) = fresh_queue(); + queue.enqueue(FlakyThenDifferentErrorOp { calls: 0 }); + + let outcome = queue.pump(repo, &CliApplier).expect("pump"); + assert!( + matches!(outcome, OpOutcome::Failed(_, ApplyError::GitSpawn(_))), + "expected the retry's own GitSpawn error to propagate, got {outcome:?}" + ); + assert_eq!( + *sleep_calls.lock().unwrap(), + 1, + "expected exactly one retry sleep (only the first attempt was a lock error)" + ); + } + #[test] fn non_lock_error_fails_immediately_without_retry() { let fixture = FixtureBuilder::new() From e91352403b2fc7231c1266819eedd3ea9888e136 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 01:36:49 -0400 Subject: [PATCH 016/203] test(review): round-trip corpus across git2 and CLI appliers --- git-workon-review/tests/roundtrip_corpus.rs | 972 ++++++++++++++++++++ 1 file changed, 972 insertions(+) create mode 100644 git-workon-review/tests/roundtrip_corpus.rs diff --git a/git-workon-review/tests/roundtrip_corpus.rs b/git-workon-review/tests/roundtrip_corpus.rs new file mode 100644 index 0000000..1e78280 --- /dev/null +++ b/git-workon-review/tests/roundtrip_corpus.rs @@ -0,0 +1,972 @@ +//! The round-trip verdict corpus (trap 6): every write-path scenario from M2's trap corpus, +//! driven through the `ops.rs` entry points and run against BOTH backends — +//! [`workon_review::apply::CliApplier`] (the oracle) and [`workon_review::apply::Git2Applier`] +//! (the backend under verification). +//! +//! This corpus is the PERMANENT dual-backend guard for the write path: a future libgit2 upgrade +//! flips the verdict recorded in `docs/rfc/workon-review.md` WITH EVIDENCE by making +//! `corpus_against_git2` fail here, not by someone remembering to re-audit the appliers by hand. +//! +//! The corpus asserts END-STATE equivalence only — index bytes, workdir bytes, error taxonomy — +//! never patch-byte equivalence. `Git2Applier`'s `Reverse` direction is `PatchText::invert()` +//! followed by a forward apply (libgit2's `Repository::apply` has no reverse flag), so the +//! bytes it hands to libgit2 for an unstage/discard legitimately differ from what `CliApplier` +//! sends `git apply --reverse` on stdin, even when both backends land the repository in the +//! identical state. Comparing intermediate patch bytes would produce false divergences; the two +//! tests below never do. + +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::Path; + +use git2::Repository; +use git_workon_fixture::prelude::*; +use workon_review::acquire::{diff_committed, diff_uncommitted}; +use workon_review::apply::{Applier, CliApplier, Git2Applier, StageVerb}; +use workon_review::error::{ReviewError, SynthesisError}; +use workon_review::model::{FileChange, FileStatus, LineKind}; +use workon_review::ops::{apply_file, apply_hunk, apply_lines}; +use workon_review::synthesis::LineSelection; + +/// Find the `hunk.lines` index of the first line of `kind` whose content matches `content` +/// exactly — lets scenarios key a [`LineSelection`] off readable content instead of hard-coded +/// positions (borrowed from `tests/line_synthesis.rs`'s helper of the same name). +fn line_index(file: &FileChange, hunk_idx: usize, kind: LineKind, content: &str) -> usize { + file.hunks[hunk_idx] + .lines + .iter() + .position(|l| l.kind == kind && l.content == content.as_bytes()) + .unwrap_or_else(|| panic!("no {kind:?} line with content {content:?} in hunk {hunk_idx}")) +} + +type BuildFn = fn() -> Fixture; +type OpsFn = fn(&Repository, &dyn Applier) -> Result<(), ReviewError>; +type VerifyFn = fn(&Fixture); + +/// One row of the corpus: builds a fixture, drives it through `ops.rs`, then asserts end-state. +/// `ops`/`verify` are plain function pointers (no captured state) — a scenario that needs to +/// thread specifics from build to verify bakes them into its own functions as literals rather +/// than widening this struct. +struct Scenario { + name: &'static str, + build: BuildFn, + ops: OpsFn, + verify: VerifyFn, +} + +fn scenarios() -> Vec { + vec![ + Scenario { + name: "whole_hunk_stage", + build: whole_hunk_build, + ops: whole_hunk_stage_ops, + verify: whole_hunk_stage_verify, + }, + Scenario { + name: "whole_hunk_unstage", + build: whole_hunk_build, + ops: whole_hunk_unstage_ops, + verify: whole_hunk_unstage_verify, + }, + Scenario { + name: "whole_hunk_discard", + build: whole_hunk_build, + ops: whole_hunk_discard_ops, + verify: whole_hunk_discard_verify, + }, + Scenario { + name: "partial_stage_adds_only", + build: adds_only_build, + ops: partial_stage_adds_only_ops, + verify: partial_stage_adds_only_verify, + }, + Scenario { + name: "partial_stage_dels_only", + build: dels_only_build, + ops: partial_stage_dels_only_ops, + verify: partial_stage_dels_only_verify, + }, + Scenario { + name: "partial_stage_mixed", + build: two_change_build, + ops: partial_stage_mixed_ops, + verify: partial_stage_mixed_verify, + }, + Scenario { + name: "partial_unstage", + build: two_change_build, + ops: partial_unstage_ops, + verify: partial_unstage_verify, + }, + Scenario { + name: "partial_discard", + build: two_change_build, + ops: partial_discard_ops, + verify: partial_discard_verify, + }, + Scenario { + name: "eofnl_whole_hunk_stage", + build: eofnl_whole_build, + ops: eofnl_whole_stage_ops, + verify: eofnl_whole_stage_verify, + }, + Scenario { + name: "eofnl_whole_hunk_unstage", + build: eofnl_whole_build, + ops: eofnl_whole_unstage_ops, + verify: eofnl_whole_unstage_verify, + }, + Scenario { + name: "eofnl_whole_hunk_discard", + build: eofnl_whole_build, + ops: eofnl_whole_discard_ops, + verify: eofnl_whole_discard_verify, + }, + Scenario { + name: "eofnl_partial_splice_stage", + build: eofnl_splice_build, + ops: eofnl_splice_stage_ops, + verify: eofnl_splice_stage_verify, + }, + Scenario { + name: "multi_hunk_single_hunk_staged", + build: multi_hunk_build, + ops: multi_hunk_ops, + verify: multi_hunk_verify, + }, + Scenario { + name: "space_in_filename_stage", + build: space_in_filename_build, + ops: space_in_filename_ops, + verify: space_in_filename_verify, + }, + Scenario { + name: "rename_read_only", + build: rename_build, + ops: rename_ops, + verify: rename_verify, + }, + Scenario { + name: "untracked_stage", + build: untracked_build, + ops: untracked_stage_ops, + verify: untracked_stage_verify, + }, + Scenario { + name: "deleted_stage", + build: deleted_build, + ops: deleted_stage_ops, + verify: deleted_stage_verify, + }, + Scenario { + name: "discard_untracked", + build: untracked_build, + ops: discard_untracked_ops, + verify: discard_untracked_verify, + }, + Scenario { + name: "unstage_staged_new", + build: staged_new_build, + ops: unstage_staged_new_ops, + verify: unstage_staged_new_verify, + }, + Scenario { + name: "refusal_lines_on_untracked", + build: untracked_build, + ops: refusal_lines_on_untracked_ops, + verify: refusal_lines_on_untracked_verify, + }, + Scenario { + name: "refusal_lines_on_deleted", + build: deleted_build, + ops: refusal_lines_on_deleted_ops, + verify: refusal_lines_on_deleted_verify, + }, + Scenario { + name: "staging_storm", + build: staging_storm_build, + ops: staging_storm_ops, + verify: staging_storm_verify, + }, + ] +} + +// --------------------------------------------------------------------------------------------- +// whole-hunk stage/unstage/discard +// --------------------------------------------------------------------------------------------- + +const WHOLE_HUNK_COMMITTED: &str = "line1\nline2\nline3\n"; +const WHOLE_HUNK_MODIFIED: &str = "line1\nCHANGED\nline3\n"; + +fn whole_hunk_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", WHOLE_HUNK_COMMITTED, WHOLE_HUNK_MODIFIED) + .build() + .expect("fixture build") +} + +fn whole_hunk_stage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Stage) +} + +fn whole_hunk_stage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + WHOLE_HUNK_MODIFIED.as_bytes().to_vec(), + )); + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + WHOLE_HUNK_MODIFIED.as_bytes().to_vec(), + )); +} + +fn whole_hunk_unstage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + // Setup: stage the full modification directly so the staged model sees it — the Unstage + // patch's preimage is the index (plan risk #3), not what's under test here. + // + // `read(true)` forces a reload from disk before mutating: `Fixture`'s `Repository` handle + // can carry an in-memory index cached from before the fixture builder's baseline commit, and + // `write()` after `add_path` would otherwise silently drop every OTHER path's entries back + // out of the on-disk index (harmless with one file in the fixture, corrupting with more than + // one — see `staging_storm_ops`, which needs this for real). + let mut index = repo.index()?; + index.read(true)?; + index.add_path(Path::new("f.txt"))?; + index.write()?; + + let diffs = diff_uncommitted(repo)?; + let file = &diffs.staged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Unstage) +} + +fn whole_hunk_unstage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + WHOLE_HUNK_COMMITTED.as_bytes().to_vec(), + )); +} + +fn whole_hunk_discard_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Discard) +} + +fn whole_hunk_discard_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + WHOLE_HUNK_COMMITTED.as_bytes().to_vec(), + )); +} + +// --------------------------------------------------------------------------------------------- +// partial stage: adds-only / dels-only / mixed; partial unstage; partial discard +// --------------------------------------------------------------------------------------------- + +const ADDS_ONLY_COMMITTED: &str = "line1\nline2\nline3\n"; +const ADDS_ONLY_MODIFIED: &str = "line1\nNEW\nline2\nline3\n"; + +fn adds_only_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", ADDS_ONLY_COMMITTED, ADDS_ONLY_MODIFIED) + .build() + .expect("fixture build") +} + +fn partial_stage_adds_only_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let keep_add = line_index(file, 0, LineKind::Addition, "NEW\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn partial_stage_adds_only_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + ADDS_ONLY_MODIFIED.as_bytes().to_vec(), + )); +} + +const DELS_ONLY_COMMITTED: &str = "line1\nline2\nline3\n"; +const DELS_ONLY_MODIFIED: &str = "line1\nline3\n"; + +fn dels_only_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", DELS_ONLY_COMMITTED, DELS_ONLY_MODIFIED) + .build() + .expect("fixture build") +} + +fn partial_stage_dels_only_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let keep_del = line_index(file, 0, LineKind::Deletion, "line2\n"); + let sel = LineSelection { + keep_adds: [].into(), + keep_dels: [keep_del].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn partial_stage_dels_only_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + DELS_ONLY_MODIFIED.as_bytes().to_vec(), + )); +} + +/// Two separate changes ("old2"->"new2", "old4"->"new4") in one hunk, separated by a context +/// line — the shape the direction rules (trap 1) need: keeping one change and dropping the +/// other must not treat the dropped one uniformly across stage/unstage/discard. +const TWO_CHANGE_COMMITTED: &str = "line1\nold2\nline3\nold4\nline5\n"; +const TWO_CHANGE_MODIFIED: &str = "line1\nnew2\nline3\nnew4\nline5\n"; + +fn two_change_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", TWO_CHANGE_COMMITTED, TWO_CHANGE_MODIFIED) + .build() + .expect("fixture build") +} + +fn first_change_selection(file: &FileChange) -> LineSelection { + let keep_add = line_index(file, 0, LineKind::Addition, "new2\n"); + let keep_del = line_index(file, 0, LineKind::Deletion, "old2\n"); + LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [keep_del].into(), + } +} + +fn partial_stage_mixed_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let sel = first_change_selection(file); + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn partial_stage_mixed_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nnew2\nline3\nold4\nline5\n".to_vec(), + )); +} + +fn partial_unstage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + // Setup: stage the full modification first so the staged model (the correct preimage for + // an Unstage patch) sees both changes. `read(true)`: see `whole_hunk_unstage_ops`. + let mut index = repo.index()?; + index.read(true)?; + index.add_path(Path::new("f.txt"))?; + index.write()?; + + let diffs = diff_uncommitted(repo)?; + let file = &diffs.staged.files[0]; + let sel = first_change_selection(file); + apply_lines(repo, applier, file, 0, &sel, StageVerb::Unstage) +} + +fn partial_unstage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"line1\nold2\nline3\nnew4\nline5\n".to_vec(), + )); +} + +fn partial_discard_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let sel = first_change_selection(file); + apply_lines(repo, applier, file, 0, &sel, StageVerb::Discard) +} + +fn partial_discard_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + b"line1\nold2\nline3\nnew4\nline5\n".to_vec(), + )); + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + TWO_CHANGE_COMMITTED.as_bytes().to_vec(), + )); +} + +// --------------------------------------------------------------------------------------------- +// EOFNL per verb (whole-hunk) + the trap-2 splice case (partial) +// --------------------------------------------------------------------------------------------- + +const EOFNL_WHOLE_COMMITTED: &str = "line1\nline2\nline3\n"; +const EOFNL_WHOLE_MODIFIED: &str = "line1\nline2\nline3"; // no trailing newline + +fn eofnl_whole_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", EOFNL_WHOLE_COMMITTED, EOFNL_WHOLE_MODIFIED) + .build() + .expect("fixture build") +} + +fn eofnl_whole_stage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Stage) +} + +fn eofnl_whole_stage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + EOFNL_WHOLE_MODIFIED.as_bytes().to_vec(), + )); +} + +fn eofnl_whole_unstage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + // `read(true)`: see `whole_hunk_unstage_ops`. + let mut index = repo.index()?; + index.read(true)?; + index.add_path(Path::new("f.txt"))?; + index.write()?; + + let diffs = diff_uncommitted(repo)?; + let file = &diffs.staged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Unstage) +} + +fn eofnl_whole_unstage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + EOFNL_WHOLE_COMMITTED.as_bytes().to_vec(), + )); +} + +fn eofnl_whole_discard_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Discard) +} + +fn eofnl_whole_discard_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + EOFNL_WHOLE_COMMITTED.as_bytes().to_vec(), + )); +} + +/// Trap-2's precondition: the committed file's last line ("last") has no trailing newline; the +/// modification deletes that line and adds two new ones, the last of which ("more\n") DOES end +/// in a newline. Keeping only "more\n" drops the "last" deletion to context while it still +/// carries `missing_newline` — the shape `splice_eofnl_context_lines` exists to rewrite. +const EOFNL_SPLICE_COMMITTED: &str = "a\nb\nlast"; +const EOFNL_SPLICE_MODIFIED: &str = "a\nb\nreplaced\nmore\n"; + +fn eofnl_splice_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", EOFNL_SPLICE_COMMITTED, EOFNL_SPLICE_MODIFIED) + .build() + .expect("fixture build") +} + +fn eofnl_splice_stage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let keep_add = line_index(file, 0, LineKind::Addition, "more\n"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn eofnl_splice_stage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + b"a\nb\nlast\nmore\n".to_vec(), + )); +} + +// --------------------------------------------------------------------------------------------- +// multi-hunk file: two changes far enough apart to form separate hunks; stage only one +// --------------------------------------------------------------------------------------------- + +fn multi_hunk_committed() -> String { + (1..=20).map(|n| format!("line{n}\n")).collect() +} + +fn multi_hunk_modified() -> String { + (1..=20) + .map(|n| match n { + 2 => "CHANGED2\n".to_string(), + 18 => "CHANGED18\n".to_string(), + n => format!("line{n}\n"), + }) + .collect() +} + +fn multi_hunk_hunk0_only() -> String { + (1..=20) + .map(|n| match n { + 2 => "CHANGED2\n".to_string(), + n => format!("line{n}\n"), + }) + .collect() +} + +fn multi_hunk_build() -> Fixture { + let committed = multi_hunk_committed(); + let modified = multi_hunk_modified(); + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", &committed, &modified) + .build() + .expect("fixture build") +} + +fn multi_hunk_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + assert_eq!( + file.hunks.len(), + 2, + "expected two separate hunks for the multi-hunk fixture, got {}", + file.hunks.len() + ); + apply_hunk(repo, applier, file, 0, StageVerb::Stage) +} + +fn multi_hunk_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "f.txt", + multi_hunk_hunk0_only().into_bytes(), + )); + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + multi_hunk_modified().into_bytes(), + )); +} + +// --------------------------------------------------------------------------------------------- +// space-in-filename: proves header path handling through both parsers +// --------------------------------------------------------------------------------------------- + +const SPACE_FILENAME_COMMITTED: &str = "line1\nline2\nline3\n"; +const SPACE_FILENAME_MODIFIED: &str = "line1\nCHANGED\nline3\n"; + +fn space_in_filename_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "my file.txt", + SPACE_FILENAME_COMMITTED, + SPACE_FILENAME_MODIFIED, + ) + .build() + .expect("fixture build") +} + +fn space_in_filename_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Stage) +} + +fn space_in_filename_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "my file.txt", + SPACE_FILENAME_MODIFIED.as_bytes().to_vec(), + )); +} + +// --------------------------------------------------------------------------------------------- +// rename: READ-side only. Rename patches only arise from tree_to_tree diffs (diff_committed), +// never from uncommitted diffs — staging a rename hunk against the index is not a v1 write op, +// so this scenario has no write side to drive through ops.rs; `ops` is a deliberate no-op and +// the assertions live entirely in `verify`. +// --------------------------------------------------------------------------------------------- + +const RENAME_CONTENT: &str = "shared content across the rename\nline two\nline three\n"; + +fn rename_build() -> Fixture { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .bare(true) + .worktree("main") + .build() + .expect("fixture build"); + + fixture + .commit("main") + .file("old.txt", RENAME_CONTENT) + .create("add old.txt") + .expect("commit old.txt"); + + // CommitBuilder only adds files; build the rename commit by hand (remove old.txt, add + // new.txt with identical content so `find_similar` detects it as a rename, not a + // delete+add pair). Scoped so every borrow of `fixture`/`repo` ends before it's returned. + { + let repo = fixture.repo().expect("repo"); + let workdir = repo.workdir().expect("workdir"); + std::fs::remove_file(workdir.join("old.txt")).expect("remove old.txt"); + std::fs::write(workdir.join("new.txt"), RENAME_CONTENT).expect("write new.txt"); + + let mut index = repo.index().expect("index"); + index + .remove_path(Path::new("old.txt")) + .expect("remove_path"); + index.add_path(Path::new("new.txt")).expect("add_path"); + index.write().expect("index write"); + let tree_id = index.write_tree().expect("write_tree"); + let tree = repo.find_tree(tree_id).expect("find_tree"); + let sig = git2::Signature::now("Test User", "test@example.com").expect("signature"); + let parent = repo.head().expect("head").peel_to_commit().expect("peel"); + repo.commit( + Some("HEAD"), + &sig, + &sig, + "rename old.txt to new.txt", + &tree, + &[&parent], + ) + .expect("commit rename"); + } + + fixture +} + +fn rename_ops(_repo: &Repository, _applier: &dyn Applier) -> Result<(), ReviewError> { + Ok(()) +} + +fn rename_verify(fixture: &Fixture) { + let repo = fixture.repo().expect("repo"); + let head_commit = repo.head().expect("head").peel_to_commit().expect("peel"); + let head_oid = head_commit.id(); + let base_oid = head_commit.parent(0).expect("parent commit").id(); + + let diff = diff_committed(repo, base_oid, head_oid).expect("diff_committed"); + let file = diff + .files + .iter() + .find(|f| f.path == "new.txt") + .expect("renamed file present as new.txt"); + + assert_eq!( + file.status, + FileStatus::Renamed, + "expected Renamed status, got {:?}", + file.status + ); + assert_eq!(file.old_path.as_deref(), Some("old.txt")); +} + +// --------------------------------------------------------------------------------------------- +// untracked / added / deleted file ops +// --------------------------------------------------------------------------------------------- + +fn untracked_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "hello\n") + .build() + .expect("fixture build") +} + +fn untracked_stage_ops(repo: &Repository, _applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Stage) +} + +fn untracked_stage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::has_staged_file("new.txt")); + fixture.assert(predicate::repo::index_blob_equals( + "new.txt", + b"hello\n".to_vec(), + )); +} + +fn deleted_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .deleted_file("gone.txt", "content\n") + .build() + .expect("fixture build") +} + +fn deleted_stage_ops(repo: &Repository, _applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Stage) +} + +fn deleted_stage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::has_staged_deletion("gone.txt")); +} + +fn discard_untracked_ops(repo: &Repository, _applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_file(repo, file, StageVerb::Discard) +} + +fn discard_untracked_verify(fixture: &Fixture) { + let repo = fixture.repo().expect("repo"); + assert!(!repo.workdir().unwrap().join("new.txt").exists()); +} + +fn staged_new_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("added.txt", "hello\n") + .build() + .expect("fixture build") +} + +fn unstage_staged_new_ops(repo: &Repository, _applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.staged.files[0]; + apply_file(repo, file, StageVerb::Unstage) +} + +fn unstage_staged_new_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::has_untracked_file("added.txt")); + let repo = fixture.repo().expect("repo"); + let mut index = repo.index().expect("index"); + index.read(true).expect("index reload"); + assert!( + index.get_path(Path::new("added.txt"), 0).is_none(), + "expected no index entry for added.txt after unstage" + ); +} + +// --------------------------------------------------------------------------------------------- +// 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. +// --------------------------------------------------------------------------------------------- + +fn refusal_lines_on_untracked_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let sel = LineSelection::default(); + let result = apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage); + assert!( + matches!( + result, + Err(ReviewError::Synthesis( + SynthesisError::LineSelectionUnsupported { .. } + )) + ), + "expected LineSelectionUnsupported, got {result:?}" + ); + Ok(()) +} + +fn refusal_lines_on_untracked_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::has_untracked_file("new.txt")); +} + +fn refusal_lines_on_deleted_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let sel = LineSelection::default(); + let result = apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage); + assert!( + matches!( + result, + Err(ReviewError::Synthesis( + SynthesisError::LineSelectionUnsupported { .. } + )) + ), + "expected LineSelectionUnsupported, got {result:?}" + ); + Ok(()) +} + +fn refusal_lines_on_deleted_verify(_fixture: &Fixture) { + // Nothing was ever applied — the refusal itself (asserted in `ops`) is the whole scenario. +} + +// --------------------------------------------------------------------------------------------- +// staging storm: stage a subset, unstage a different subset, discard the rest in one sequence, +// then assert the exact three-way (index/workdir) end state. Driven through `apply_hunk` (not +// `apply_file`) so the sequence exercises the `Applier` on all three files, not just index +// plumbing — `StagingQueue` integration was considered (plan explicitly allows it) but skipped: +// queue.rs's own test suite already covers FIFO/live-index semantics, and wiring `StagingOp` +// here would test queue plumbing instead of the storm's actual end-state, which is the point of +// this scenario. +// --------------------------------------------------------------------------------------------- + +const STORM_A_COMMITTED: &str = "a1\na2\na3\n"; +const STORM_A_MODIFIED: &str = "a1\nCHANGED_A\na3\n"; +const STORM_B_COMMITTED: &str = "b1\nb2\nb3\n"; +const STORM_B_MODIFIED: &str = "b1\nCHANGED_B\nb3\n"; +const STORM_C_COMMITTED: &str = "c1\nc2\nc3\n"; +const STORM_C_MODIFIED: &str = "c1\nCHANGED_C\nc3\n"; + +fn staging_storm_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("fileA.txt", STORM_A_COMMITTED, STORM_A_MODIFIED) + .unstaged_file("fileB.txt", STORM_B_COMMITTED, STORM_B_MODIFIED) + .unstaged_file("fileC.txt", STORM_C_COMMITTED, STORM_C_MODIFIED) + .build() + .expect("fixture build") +} + +fn staging_storm_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + // Setup: pre-stage fileB's modification (not part of the storm itself) so the storm can + // unstage it. `read(true)` is load-bearing here, not defensive: `Fixture`'s `Repository` + // handle can carry an in-memory index cached from before the fixture builder's baseline + // commit (which added fileA/fileB/fileC together); without a reload first, `add_path` + + // `write` writes back only what THIS index object knows about, silently dropping fileA's + // and fileC's index entries (verified empirically — the naive form staged all three files + // as untracked-since-deleted). + let mut index = repo.index()?; + index.read(true)?; + index.add_path(Path::new("fileB.txt"))?; + index.write()?; + + let diffs = diff_uncommitted(repo)?; + let file_a = diffs + .unstaged + .files + .iter() + .find(|f| f.path == "fileA.txt") + .expect("fileA.txt in unstaged diff"); + let file_c = diffs + .unstaged + .files + .iter() + .find(|f| f.path == "fileC.txt") + .expect("fileC.txt in unstaged diff"); + let file_b = diffs + .staged + .files + .iter() + .find(|f| f.path == "fileB.txt") + .expect("fileB.txt in staged diff"); + + apply_hunk(repo, applier, file_a, 0, StageVerb::Stage)?; + apply_hunk(repo, applier, file_b, 0, StageVerb::Unstage)?; + apply_hunk(repo, applier, file_c, 0, StageVerb::Discard)?; + Ok(()) +} + +fn staging_storm_verify(fixture: &Fixture) { + // Staged: index has the change, workdir untouched (Stage targets the index only). + fixture.assert(predicate::repo::index_blob_equals( + "fileA.txt", + STORM_A_MODIFIED.as_bytes().to_vec(), + )); + fixture.assert(predicate::repo::workdir_file_equals( + "fileA.txt", + STORM_A_MODIFIED.as_bytes().to_vec(), + )); + // Unstaged: index reverted to HEAD, workdir untouched (Unstage targets the index only). + fixture.assert(predicate::repo::index_blob_equals( + "fileB.txt", + STORM_B_COMMITTED.as_bytes().to_vec(), + )); + fixture.assert(predicate::repo::workdir_file_equals( + "fileB.txt", + STORM_B_MODIFIED.as_bytes().to_vec(), + )); + // Discarded: workdir reverted to HEAD; index was never touched (still matches HEAD). + fixture.assert(predicate::repo::workdir_file_equals( + "fileC.txt", + STORM_C_COMMITTED.as_bytes().to_vec(), + )); + fixture.assert(predicate::repo::index_blob_equals( + "fileC.txt", + STORM_C_COMMITTED.as_bytes().to_vec(), + )); +} + +// --------------------------------------------------------------------------------------------- +// The verdict tests +// --------------------------------------------------------------------------------------------- + +/// The ORACLE: every scenario must pass cleanly against `CliApplier`. No skip-if-missing — the +/// `git` binary is required. A panic here is a real bug in the corpus or the write path, not a +/// divergence to collect. +#[test] +fn corpus_against_cli() { + for scenario in scenarios() { + let fixture = (scenario.build)(); + let repo = fixture.repo().expect("repo"); + (scenario.ops)(repo, &CliApplier) + .unwrap_or_else(|err| panic!("{}: CLI ops failed: {err}", scenario.name)); + (scenario.verify)(&fixture); + } +} + +/// Run one scenario's ops+verify against `Git2Applier`, catching panics so one scenario's +/// failure doesn't abort the rest of the corpus. +/// +/// SOUNDNESS of `AssertUnwindSafe`: `&Repository` isn't `UnwindSafe`, so the closure capturing +/// `repo`/`fixture` can't be passed to `catch_unwind` without asserting it. This is sound +/// because each scenario's fixture is built fresh, used exactly once, and dropped immediately +/// after — whether or not a panic occurs — so there is no unwind-poisoned shared state for a +/// later scenario (or a later assertion on the SAME fixture) to observe. +fn run_scenario_against_git2(scenario: &Scenario) -> Result<(), String> { + let fixture = (scenario.build)(); + let outcome = catch_unwind(AssertUnwindSafe(|| { + let repo = fixture.repo().expect("repo"); + (scenario.ops)(repo, &Git2Applier).map_err(|err| format!("git2 ops errored: {err}"))?; + (scenario.verify)(&fixture); + Ok::<(), String>(()) + })); + match outcome { + Ok(Ok(())) => Ok(()), + Ok(Err(detail)) => Err(detail), + Err(_) => Err("git2 pass panicked (ops error or a verify assertion failed)".to_string()), + } +} + +/// Known divergences between `Git2Applier` and the `CliApplier` oracle, one entry per +/// `": "`. Kept explicit (rather than a bare `assert!(is_empty())`) +/// so a future divergence must be added here WITH the evidence of what it is, not silently +/// swallowed by loosening this test. +const KNOWN_DIVERGENCES: &[&str] = &[]; + +/// The VERDICT: renders the git2-vs-CLI comparison for `docs/rfc/workon-review.md`'s "M2 +/// verdict" section. Collects divergences instead of panicking per-scenario so the full set is +/// visible in one run. +#[test] +fn corpus_against_git2() { + let mut divergences: Vec = Vec::new(); + for scenario in scenarios() { + if let Err(detail) = run_scenario_against_git2(&scenario) { + divergences.push(format!("{}: {detail}", scenario.name)); + } + } + divergences.sort(); + + let mut expected: Vec = KNOWN_DIVERGENCES.iter().map(|s| s.to_string()).collect(); + expected.sort(); + + assert_eq!( + divergences, expected, + "git2 divergence set changed vs. KNOWN_DIVERGENCES — update the allowlist WITH evidence, \ + don't just widen it to pass" + ); +} From e5154f7c6793f711082ae71ebf353e9487e2d229 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 01:40:15 -0400 Subject: [PATCH 017/203] docs(rfc): record git2-vs-CLI verdict and write-path decision --- docs/rfc/workon-review.md | 45 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index d79c6b5..c8b28ac 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -48,11 +48,54 @@ Hard-won semantics from the prototype, all of which caused real bugs. Each becom 6. **git2 re-verification**: all of the above were validated against git CLI. Re-run the round-trip corpus against libgit2's apply/index. Divergence → shell out to `git apply` for writes (reads stay git2). 7. **Metadata revisions are snapshots, not refs** (found dogfooding the prototype on this repo, 2026-07-05): graphite's `branch_revision` updates only when gt runs — commits made with plain git (i.e. any commit made outside gt) leave it stale. The prototype used it as the changeset head, so a freshly-committed branch rendered an EMPTY changeset (`head_rev == parent_rev ==` fork point) while still appearing in the stack. Changeset head must resolve the live ref (`refs/heads/`); `parentBranchRevision` remains the correct BASE (diff-as-authored + needs-restack input) — do not "fix" it to live trunk. Related: the prototype swallows per-changeset diff errors into an empty file list — a failed diff must be distinguishable from a genuinely empty changeset. Test: fixture branch tracked in metadata, then commits added with plain git; assert the changeset spans fork..live-head and that a bad ref surfaces an error, not an empty changeset. +## M2 verdict (git2 vs CLI apply) + +The round-trip corpus (`git-workon-review/tests/roundtrip_corpus.rs`) drives every write-path +scenario class from the trap corpus above through `ops.rs`'s entry points against both backends. +Measured result: **0 divergences** across 22 scenarios. + +| Scenario class | git2 verdict | +|---|---| +| Whole-hunk stage/unstage/discard | pass | +| Partial stage (adds-only/dels-only/mixed) | pass | +| Partial unstage / partial discard | pass | +| EOFNL per verb (whole-hunk) | pass | +| EOFNL trap-2 splice (partial stage) | pass | +| Multi-hunk file, one hunk staged | pass | +| Space-in-filename header handling | pass | +| Rename (read-side, `diff_committed`) | pass | +| Untracked/added/deleted file ops | pass | +| Line-selection refusals (never reach an applier) | pass | +| Staging storm (mixed stage/unstage/discard, three-way end state) | pass | + +Per the plan's decision procedure: 0 divergences means **`Git2Applier` is the default write +path**; `CliApplier` is retained as the corpus's oracle and as the documented escape hatch +(`is_lock_contention` already classifies errors from both backends identically, so the seam has +no additional cost to keep). `Applier` stays a trait specifically so this can flip without +touching call sites if a future libgit2 upgrade regresses. + +`tests/roundtrip_corpus.rs` runs both backends on every `cargo test` — it is the permanent guard +this decision rests on. If a future libgit2 upgrade changes apply behavior, `corpus_against_git2` +fails with the specific scenario and divergence class, and the fix is to update +`KNOWN_DIVERGENCES` (or flip the default writer) with that evidence in hand, not to relitigate +this section from memory. + +Two tripwire findings from earlier M2 changesets are now pinned as permanent regression tests, +not just corpus coverage: + +- **Trap 3 (empty-blob deletion staging)**: `naive_hunk_stage_of_deletion_stages_empty_blob` in + `git-workon-review/tests/file_ops.rs` — a naive whole-hunk stage of a deletion is accepted by + `git apply --cached` but stages an empty blob instead of removing the index entry. +- **Trap 2 (EOFNL silent concatenation)**: `naive_unspliced_eofnl_patch_silently_corrupts_the_index` + in `git-workon-review/tests/line_synthesis.rs` — a dropped deletion converted to context while + still carrying its `\ No newline at end of file` marker, followed by a kept line, is accepted + by `git apply` (exit 0) but silently concatenates the two lines into one corrupt line. + ## Milestones - **M0 — workspace plumbing.** New member crate `git-workon-review` (lib+bin, clap, error model matching workspace: thiserror+miette). Toolchain bump (ratatui/tree-sitter won't meet 1.68.2; resolved: workspace-wide `rust-version = 1.88` — no crate had ever inherited the old value, so there was no lib MSRV to preserve). Lib hygiene (drop unused dialoguer/env_logger). CI: tree-sitter C builds. Release posture per [ADR-027](../adr/027-review-crate-workspace-placement.md): `publish = false` keeps the crate out of release-plz and cargo-dist entirely; release-plz wiring is deliberately deferred to the M3 flip — do NOT add a release-plz.toml entry in M0. Acceptance: `cargo build --workspace` green, empty `git-workon-review` binary runs and prints help. - **M1 — fixture extensions + lib stack capabilities (test-first).** Fixture: sqlite metadata mode (also finally exercises the lib's primary read path), index-state builders. Lib: `parentBranchRevision` read (both formats) + needs-restack; git-inference StackModel; changeset assembly API (`Vec {branch, base_ref, head_ref, title, current, needs_restack}` + uncommitted layer). Acceptance: existing lib tests green + new capabilities spec'd against fixtures in both metadata formats. -- **M2 — trap corpus port.** Diff parser + patch synthesis in the review lib, the six trap items as tests, git2-vs-CLI verdict rendered (and the write-path decision recorded here). Acceptance: round-trip corpus green against real repos. +- **M2 — trap corpus port.** Diff parser + patch synthesis in the review lib, the six trap items as tests, git2-vs-CLI verdict rendered (and the write-path decision recorded here). Acceptance: round-trip corpus green against real repos. — DONE (2026-07-06): corpus green on both backends; verdict recorded above. - **M3 — renderer + uncommitted source.** Port spike modules; wire changeset → parsed diff → SBS/inline render; file nav; the uncommitted source end-to-end. Acceptance: dogfood-able read-only review of a dirty worktree. - **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. - **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). From 02fa991bc5da0a8de571242e7a6fc4b56503d944 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 17:59:39 -0400 Subject: [PATCH 018/203] fix(review): downcast corpus panics and pin exec-bit scenario --- git-workon-fixture/src/fixture_builder.rs | 61 ++++++++- git-workon-fixture/src/predicates.rs | 2 + .../src/predicates/has_index_mode.rs | 49 +++++++ git-workon-fixture/src/prelude.rs | 12 +- git-workon-fixture/tests/suite/index_state.rs | 25 ++++ git-workon-review/tests/roundtrip_corpus.rs | 125 +++++++++++++----- 6 files changed, 230 insertions(+), 44 deletions(-) create mode 100644 git-workon-fixture/src/predicates/has_index_mode.rs diff --git a/git-workon-fixture/src/fixture_builder.rs b/git-workon-fixture/src/fixture_builder.rs index e67c27c..3b6e5a7 100644 --- a/git-workon-fixture/src/fixture_builder.rs +++ b/git-workon-fixture/src/fixture_builder.rs @@ -147,6 +147,7 @@ pub struct FixtureBuilder<'fixture> { deleted_files: Vec<(String, String)>, // (path, committed) 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 } impl<'fixture> FixtureBuilder<'fixture> { @@ -170,6 +171,7 @@ impl<'fixture> FixtureBuilder<'fixture> { deleted_files: Vec::new(), partially_staged_files: Vec::new(), untracked_symlinks: Vec::new(), + executable_unstaged_files: Vec::new(), } } @@ -407,6 +409,24 @@ impl<'fixture> FixtureBuilder<'fixture> { self } + /// Like [`unstaged_file`](Self::unstaged_file), but `path` is committed and rewritten with + /// the executable bit set (`chmod 0o755`) at BOTH baseline commit time and after the + /// working-tree rewrite — so `HEAD`'s (and the starting index's) mode is really + /// `0o100755`, not just the working tree's. Needed to pin the exec-bit-preserving fix: a + /// hunk stage of an executable file must not clobber its index mode back to `0o100644`. + /// + /// 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_unstaged_file(mut self, path: &str, committed: &str, modified: &str) -> Self { + self.executable_unstaged_files.push(( + path.to_string(), + committed.to_string(), + modified.to_string(), + )); + self + } + /// Create a symlink at `path` pointing at `target` in the fixture's cwd repo working tree; /// never staged (untracked). `target` need not exist — a dangling/broken symlink is still a /// real working-tree entry (`symlink_metadata`/lstat sees it; `Path::exists`, which follows @@ -530,22 +550,25 @@ impl<'fixture> FixtureBuilder<'fixture> { || !self.untracked_files.is_empty() || !self.deleted_files.is_empty() || !self.partially_staged_files.is_empty() - || !self.untracked_symlinks.is_empty(); + || !self.untracked_symlinks.is_empty() + || !self.executable_unstaged_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 require a working tree: fixture is bare(true) with no worktree" + untracked_symlink/executable_unstaged_file require a working tree: fixture is \ + bare(true) with no worktree" .into(), ); } - // `unstaged_file`/`deleted_file`/`partially_staged_file` baseline commits land BEFORE - // Graphite-metadata live-tip resolution below: they move the cwd branch's tip, and any - // metadata entry recording that tip must reflect the moved one, not the pre-baseline - // commit. All three builders share one baseline commit. + // `unstaged_file`/`deleted_file`/`partially_staged_file`/`executable_unstaged_file` + // baseline commits land BEFORE Graphite-metadata live-tip resolution below: they move + // the cwd branch's tip, and any metadata entry recording that tip must reflect the + // moved one, not the pre-baseline commit. All four builders share one baseline commit. if !self.unstaged_files.is_empty() || !self.deleted_files.is_empty() || !self.partially_staged_files.is_empty() + || !self.executable_unstaged_files.is_empty() { let cwd_repo = Repository::open(&cwd_path)?; let mut index = cwd_repo.index()?; @@ -573,6 +596,21 @@ impl<'fixture> FixtureBuilder<'fixture> { std::fs::write(&abs_path, committed)?; index.add_path(Path::new(file_path))?; } + #[cfg(unix)] + for (file_path, committed, _modified) in &self.executable_unstaged_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, committed)?; + std::fs::set_permissions(&abs_path, std::fs::Permissions::from_mode(0o755))?; + index.add_path(Path::new(file_path))?; + } + #[cfg(not(unix))] + if !self.executable_unstaged_files.is_empty() { + return Err("executable_unstaged_file is unix-only".into()); + } index.write()?; let tree_id = index.write_tree()?; @@ -755,6 +793,17 @@ impl<'fixture> FixtureBuilder<'fixture> { std::fs::write(cwd_path.join(file_path), modified)?; } + // Rewrite the working tree copy to `modified` content, then restore the exec bit — + // `std::fs::write` truncates+rewrites the file rather than editing it in place, so + // the mode set during the baseline commit above does not necessarily survive. + #[cfg(unix)] + for (file_path, _committed, modified) in &self.executable_unstaged_files { + use std::os::unix::fs::PermissionsExt; + let abs_path = cwd_path.join(file_path); + std::fs::write(&abs_path, modified)?; + std::fs::set_permissions(&abs_path, std::fs::Permissions::from_mode(0o755))?; + } + // Rewrite the working tree copy to `workdir` content AFTER the index has `staged` // — the index entry must stay at `staged`, only the on-disk file moves further. for (file_path, _committed, _staged, workdir) in &self.partially_staged_files { diff --git a/git-workon-fixture/src/predicates.rs b/git-workon-fixture/src/predicates.rs index 79b1ec1..f562c32 100644 --- a/git-workon-fixture/src/predicates.rs +++ b/git-workon-fixture/src/predicates.rs @@ -4,6 +4,7 @@ mod has_branch_metadata; mod has_config; mod has_config_multivar; mod has_graphite_config; +mod has_index_mode; mod has_metadata_parent_revision; mod has_remote; mod has_remote_branch; @@ -34,6 +35,7 @@ pub use self::has_branch_metadata::*; pub use self::has_config::*; pub use self::has_config_multivar::*; pub use self::has_graphite_config::*; +pub use self::has_index_mode::*; pub use self::has_metadata_parent_revision::*; pub use self::has_remote::*; pub use self::has_remote_branch::*; diff --git a/git-workon-fixture/src/predicates/has_index_mode.rs b/git-workon-fixture/src/predicates/has_index_mode.rs new file mode 100644 index 0000000..8e4ddc1 --- /dev/null +++ b/git-workon-fixture/src/predicates/has_index_mode.rs @@ -0,0 +1,49 @@ +use git2::Repository; +use predicates::prelude::Predicate; +use predicates::reflection::PredicateReflection; +use std::fmt; +use std::path::Path; + +pub struct HasIndexModePredicate { + path: String, + expected: i32, +} + +impl PredicateReflection for HasIndexModePredicate {} + +impl fmt::Display for HasIndexModePredicate { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "index entry for '{}' has mode {:06o}", + self.path, self.expected + ) + } +} + +impl Predicate for HasIndexModePredicate { + fn eval(&self, repo: &Repository) -> bool { + let Ok(mut index) = repo.index() else { + return false; + }; + // See `index_blob_equals`'s doc comment: force a reload so a stale cached index handle + // doesn't report a mode from before another handle wrote the on-disk index. + if index.read(true).is_err() { + return false; + } + let Some(entry) = index.get_path(Path::new(&self.path), 0) else { + return false; + }; + entry.mode as i32 == self.expected + } +} + +/// Assert that the index entry for `path` has raw octal mode `expected` (e.g. `0o100755` for an +/// executable file) — the regression check for staging an executable file not clobbering its +/// mode back to `0o100644`. +pub fn has_index_mode(path: impl Into, expected: i32) -> HasIndexModePredicate { + HasIndexModePredicate { + path: path.into(), + expected, + } +} diff --git a/git-workon-fixture/src/prelude.rs b/git-workon-fixture/src/prelude.rs index 2856e1b..68b5774 100644 --- a/git-workon-fixture/src/prelude.rs +++ b/git-workon-fixture/src/prelude.rs @@ -28,12 +28,12 @@ pub mod predicate { pub mod repo { pub use crate::predicates::{ branch_points_to, has_branch, has_branch_metadata, has_config, has_graphite_config, - has_metadata_parent_revision, has_no_stash, has_remote, has_remote_branch, - has_remote_url, has_sqlite_branch_metadata, has_staged_deletion, has_staged_file, - has_stash, has_unstaged_file, has_untracked_file, has_upstream, has_workdir_deletion, - has_worktree, head_commit_message_contains, head_commit_parent_count, head_matches, - index_blob_equals, is_bare, is_empty, is_head_detached, is_worktree, - workdir_file_equals, + has_index_mode, has_metadata_parent_revision, has_no_stash, has_remote, + has_remote_branch, has_remote_url, has_sqlite_branch_metadata, has_staged_deletion, + has_staged_file, has_stash, has_unstaged_file, has_untracked_file, has_upstream, + has_workdir_deletion, has_worktree, head_commit_message_contains, + head_commit_parent_count, head_matches, index_blob_equals, is_bare, is_empty, + is_head_detached, is_worktree, workdir_file_equals, }; } // Re-export predicates for convenience diff --git a/git-workon-fixture/tests/suite/index_state.rs b/git-workon-fixture/tests/suite/index_state.rs index 95330f2..994909f 100644 --- a/git-workon-fixture/tests/suite/index_state.rs +++ b/git-workon-fixture/tests/suite/index_state.rs @@ -401,3 +401,28 @@ fn untracked_symlink_is_visible_via_lstat_even_when_dangling( Ok(()) } + +#[cfg(unix)] +#[test] +fn executable_unstaged_file_has_100755_mode_at_head_and_on_disk( +) -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let fixture = FixtureBuilder::new() + .executable_unstaged_file("run.sh", "echo committed\n", "echo modified\n") + .build()?; + + let repo = fixture.repo()?; + repo.assert(predicate::repo::has_index_mode("run.sh", 0o100755)); + + let dir = fixture.cwd()?; + let abs_path = dir.path().join("run.sh"); + let perms = std::fs::metadata(&abs_path)?.permissions(); + assert_eq!( + perms.mode() & 0o111, + 0o111, + "expected the working tree copy to keep its executable bits" + ); + + Ok(()) +} diff --git a/git-workon-review/tests/roundtrip_corpus.rs b/git-workon-review/tests/roundtrip_corpus.rs index 1e78280..cf6df13 100644 --- a/git-workon-review/tests/roundtrip_corpus.rs +++ b/git-workon-review/tests/roundtrip_corpus.rs @@ -38,6 +38,24 @@ fn line_index(file: &FileChange, hunk_idx: usize, kind: LineKind, content: &str) .unwrap_or_else(|| panic!("no {kind:?} line with content {content:?} in hunk {hunk_idx}")) } +/// Stage `path`'s current working-tree content directly (bypassing `ops.rs`) as scenario setup +/// — e.g. so an Unstage/Discard scenario's preimage is already staged before the op under test +/// runs. +/// +/// `index.read(true)` forces a reload from disk before mutating: `Fixture`'s `Repository` handle +/// can carry an in-memory index cached from before the fixture builder's baseline commit, and +/// `write()` after `add_path` would otherwise silently drop every OTHER path's entries back out +/// of the on-disk index — harmless with one file in a fixture, corrupting with more than one +/// (verified empirically in `staging_storm_ops`'s three-file fixture: the naive form staged all +/// three files as untracked-since-deleted). +fn pre_stage(repo: &Repository, path: &str) -> Result<(), ReviewError> { + let mut index = repo.index()?; + index.read(true)?; + index.add_path(Path::new(path))?; + index.write()?; + Ok(()) +} + type BuildFn = fn() -> Fixture; type OpsFn = fn(&Repository, &dyn Applier) -> Result<(), ReviewError>; type VerifyFn = fn(&Fixture); @@ -187,6 +205,12 @@ fn scenarios() -> Vec { ops: staging_storm_ops, verify: staging_storm_verify, }, + Scenario { + name: "executable_whole_hunk_stage", + build: executable_build, + ops: executable_whole_hunk_stage_ops, + verify: executable_whole_hunk_stage_verify, + }, ] } @@ -224,17 +248,9 @@ fn whole_hunk_stage_verify(fixture: &Fixture) { fn whole_hunk_unstage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { // Setup: stage the full modification directly so the staged model sees it — the Unstage - // patch's preimage is the index (plan risk #3), not what's under test here. - // - // `read(true)` forces a reload from disk before mutating: `Fixture`'s `Repository` handle - // can carry an in-memory index cached from before the fixture builder's baseline commit, and - // `write()` after `add_path` would otherwise silently drop every OTHER path's entries back - // out of the on-disk index (harmless with one file in the fixture, corrupting with more than - // one — see `staging_storm_ops`, which needs this for real). - let mut index = repo.index()?; - index.read(true)?; - index.add_path(Path::new("f.txt"))?; - index.write()?; + // patch's preimage is the index (plan risk #3), not what's under test here. See + // `pre_stage`'s docs for why `index.read(true)` is load-bearing, not defensive. + pre_stage(repo, "f.txt")?; let diffs = diff_uncommitted(repo)?; let file = &diffs.staged.files[0]; @@ -368,11 +384,9 @@ fn partial_stage_mixed_verify(fixture: &Fixture) { fn partial_unstage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { // Setup: stage the full modification first so the staged model (the correct preimage for - // an Unstage patch) sees both changes. `read(true)`: see `whole_hunk_unstage_ops`. - let mut index = repo.index()?; - index.read(true)?; - index.add_path(Path::new("f.txt"))?; - index.write()?; + // an Unstage patch) sees both changes. See `pre_stage`'s docs for why `index.read(true)` is + // load-bearing. + pre_stage(repo, "f.txt")?; let diffs = diff_uncommitted(repo)?; let file = &diffs.staged.files[0]; @@ -434,11 +448,8 @@ fn eofnl_whole_stage_verify(fixture: &Fixture) { } fn eofnl_whole_unstage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { - // `read(true)`: see `whole_hunk_unstage_ops`. - let mut index = repo.index()?; - index.read(true)?; - index.add_path(Path::new("f.txt"))?; - index.write()?; + // See `pre_stage`'s docs for why `index.read(true)` is load-bearing. + pre_stage(repo, "f.txt")?; let diffs = diff_uncommitted(repo)?; let file = &diffs.staged.files[0]; @@ -834,16 +845,10 @@ fn staging_storm_build() -> Fixture { fn staging_storm_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { // Setup: pre-stage fileB's modification (not part of the storm itself) so the storm can - // unstage it. `read(true)` is load-bearing here, not defensive: `Fixture`'s `Repository` - // handle can carry an in-memory index cached from before the fixture builder's baseline - // commit (which added fileA/fileB/fileC together); without a reload first, `add_path` + - // `write` writes back only what THIS index object knows about, silently dropping fileA's - // and fileC's index entries (verified empirically — the naive form staged all three files - // as untracked-since-deleted). - let mut index = repo.index()?; - index.read(true)?; - index.add_path(Path::new("fileB.txt"))?; - index.write()?; + // unstage it. See `pre_stage`'s docs for why `index.read(true)` is load-bearing here, not + // defensive — this fixture's three-file baseline commit is exactly the multi-path case that + // bites without the reload. + pre_stage(repo, "fileB.txt")?; let diffs = diff_uncommitted(repo)?; let file_a = diffs @@ -901,6 +906,44 @@ fn staging_storm_verify(fixture: &Fixture) { )); } +// --------------------------------------------------------------------------------------------- +// executable file (100755) whole-hunk stage — pins the exec-bit-mode divergence class the +// 2026-07-06 stack review found: `PatchText::to_bytes` used to hardcode `index 0000000..0000000 +// 100644` on the synthesized patch's index line, so staging any hunk of an executable file via +// `Git2Applier` silently reset its index mode to `100644` — a real divergence `CliApplier` never +// had (it reads the mode from the working tree). Fixed by threading the real mode through +// `FileChange`/`PatchText` (see `synthesis.rs`). +// --------------------------------------------------------------------------------------------- + +const EXECUTABLE_COMMITTED: &str = "#!/bin/sh\necho committed\n"; +const EXECUTABLE_MODIFIED: &str = "#!/bin/sh\necho modified\n"; + +fn executable_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .executable_unstaged_file("run.sh", EXECUTABLE_COMMITTED, EXECUTABLE_MODIFIED) + .build() + .expect("fixture build") +} + +fn executable_whole_hunk_stage_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, applier, file, 0, StageVerb::Stage) +} + +fn executable_whole_hunk_stage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "run.sh", + EXECUTABLE_MODIFIED.as_bytes().to_vec(), + )); + // The regression: staging must NOT clobber the index entry's mode back to 0o100644. + fixture.assert(predicate::repo::has_index_mode("run.sh", 0o100755)); +} + // --------------------------------------------------------------------------------------------- // The verdict tests // --------------------------------------------------------------------------------------------- @@ -938,7 +981,25 @@ fn run_scenario_against_git2(scenario: &Scenario) -> Result<(), String> { match outcome { Ok(Ok(())) => Ok(()), Ok(Err(detail)) => Err(detail), - Err(_) => Err("git2 pass panicked (ops error or a verify assertion failed)".to_string()), + Err(payload) => Err(format!( + "git2 pass panicked: {}", + panic_payload_message(&payload) + )), + } +} + +/// Extract a human-readable message from a `catch_unwind` panic payload — `panic!("{msg}")` and +/// `assert!`/`unwrap`/`expect` failures carry it as `&'static str` or `String` depending on +/// whether the message was formatted; anything else (a non-string payload) falls back to a +/// fixed placeholder so a [`KNOWN_DIVERGENCES`] entry can still cite whatever detail IS +/// available instead of a uniform, evidence-free string for every panic. +fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(s) = payload.downcast_ref::() { + s.clone() + } else if let Some(s) = payload.downcast_ref::<&str>() { + s.to_string() + } else { + "(non-string panic payload)".to_string() } } From 9a4abbd2f32519c2adb146966f671673d767868a Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 17:59:47 -0400 Subject: [PATCH 019/203] docs(rfc): record review-found exec-bit and EOFNL divergences --- docs/rfc/workon-review.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index c8b28ac..1e3c46a 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -52,7 +52,8 @@ Hard-won semantics from the prototype, all of which caused real bugs. Each becom The round-trip corpus (`git-workon-review/tests/roundtrip_corpus.rs`) drives every write-path scenario class from the trap corpus above through `ops.rs`'s entry points against both backends. -Measured result: **0 divergences** across 22 scenarios. +Measured result (updated after the 2026-07-06 stack review, see below): **0 divergences** across +23 scenarios. | Scenario class | git2 verdict | |---|---| @@ -67,6 +68,7 @@ Measured result: **0 divergences** across 22 scenarios. | Untracked/added/deleted file ops | pass | | Line-selection refusals (never reach an applier) | pass | | Staging storm (mixed stage/unstage/discard, three-way end state) | pass | +| Executable file (100755) whole-hunk stage | pass (fixed by review — was a divergence, see below) | Per the plan's decision procedure: 0 divergences means **`Git2Applier` is the default write path**; `CliApplier` is retained as the corpus's oracle and as the documented escape hatch @@ -91,6 +93,37 @@ not just corpus coverage: still carrying its `\ No newline at end of file` marker, followed by a kept line, is accepted by `git apply` (exit 0) but silently concatenates the two lines into one corrupt line. +### Post-verdict corrections (2026-07-06 stack review) + +The "0 divergences across 22 scenarios" claim above predates a high-effort stack review that +found two more divergence classes the original corpus missed. Both were fixed in place (in the +M2 changeset that introduced them) and are now pinned in the corpus/regression suite, so the +verdict — `Git2Applier` as the default write path — **stands**; these are corrections to the +evidence, not to the conclusion. + +1. **Exec-bit mode handling** (`git-workon-review/src/synthesis.rs`): `PatchText::to_bytes` + hardcoded `index 0000000..0000000 100644` on every synthesized patch. libgit2 takes the new + index entry's mode straight from this line, so staging any hunk of a `100755` file via + `Git2Applier` silently reset its mode to `100644` — a real divergence from `CliApplier`, which + reads the mode from the working tree and never had this bug. Fixed by threading the real mode + (`FileChange::old_mode`/`new_mode`, from `delta.{old,new}_file().mode()`) onto `PatchText` and + swapping it in `PatchText::invert`. Pinned by the `executable_whole_hunk_stage` corpus + scenario (table above) and by `synthesis.rs`'s own `whole_hunk_patch_carries_real_mode_into_index_line`/`invert_swaps_old_and_new_mode` + unit tests. +2. **Kept-EOFNL-deletion under `base == New`** (`git-workon-review/src/synthesis.rs`): a KEPT + deletion carrying `missing_newline: true`, followed by a dropped addition converted to context + (`base == New`'s drop rule), produced a hunk where the two backends actually DISAGREED rather + than merely diverging in end state: `CliApplier` accepted it and silently concatenated the + next line onto the no-newline deletion (the same class of corruption as the original trap-2 + finding); `Git2Applier` rejected the patch outright (`invalid patch hunk`). In this instance + git2 was the SAFE side — refusing a malformed patch is preferable to silently corrupting a + file — which is itself evidence for, not against, the `Git2Applier`-default verdict. Fixed by + extending the trap-2 splice (`splice_eofnl_context_lines`) to also rewrite a kept deletion's + own bytes (real trailing `\n`, marker dropped) when a later emitted line is context. Pinned by + `kept_eofnl_deletion_needs_splice_under_base_new` in `git-workon-review/tests/line_synthesis.rs` + (covers both backends via `Discard`); not duplicated into the corpus since that test already + exercises the identical fixture/selection/direction against both appliers end-to-end. + ## Milestones - **M0 — workspace plumbing.** New member crate `git-workon-review` (lib+bin, clap, error model matching workspace: thiserror+miette). Toolchain bump (ratatui/tree-sitter won't meet 1.68.2; resolved: workspace-wide `rust-version = 1.88` — no crate had ever inherited the old value, so there was no lib MSRV to preserve). Lib hygiene (drop unused dialoguer/env_logger). CI: tree-sitter C builds. Release posture per [ADR-027](../adr/027-review-crate-workspace-placement.md): `publish = false` keeps the crate out of release-plz and cargo-dist entirely; release-plz wiring is deliberately deferred to the M3 flip — do NOT add a release-plz.toml entry in M0. Acceptance: `cargo build --workspace` green, empty `git-workon-review` binary runs and prints help. From 82ce450c87cdb2978f80516e8ba0746f6f633b51 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 18:13:47 -0400 Subject: [PATCH 020/203] feat(review): add combined diff and rename detection to acquire --- git-workon-review/src/acquire.rs | 55 +++++++++++++---- git-workon-review/tests/diff_model.rs | 86 +++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/git-workon-review/src/acquire.rs b/git-workon-review/src/acquire.rs index 30512e9..08f9836 100644 --- a/git-workon-review/src/acquire.rs +++ b/git-workon-review/src/acquire.rs @@ -5,44 +5,75 @@ //! (a committed rev pair, or "uncommitted"); this module only knows *how* to turn that into //! git2 diffs and then a [`DiffModel`]. -use git2::{DiffOptions, Oid, Repository}; +use git2::{DiffFindOptions, DiffOptions, Oid, Repository}; use workon::{Changeset, ChangesetSource}; use crate::error::DiffError; use crate::model::DiffModel; -/// The two working-tree diffs a review session needs: the index against `HEAD` (staged), and -/// the working tree against the index (unstaged, including untracked content). +/// The working-tree diffs a review session needs: the index against `HEAD` (staged), the +/// working tree against the index (unstaged, including untracked content), and the fused +/// `HEAD` ↔ worktree view (combined) the M3 renderer reviews by default. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorktreeDiffs { pub staged: DiffModel, pub unstaged: DiffModel, + /// `HEAD`'s tree diffed straight against the working tree (index consulted only for + /// untracked/ignore filtering), fusing staged and unstaged hunks on the same file into one + /// diff — the combined-zoom view the M3 renderer reviews (locked design decision #2). + pub combined: DiffModel, } -/// Diff `HEAD`'s tree against the index (staged) and the index against the working tree -/// (unstaged), for a [`ChangesetSource::Uncommitted`] changeset. +/// Diff `HEAD`'s tree against the index (staged), the index against the working tree +/// (unstaged), and `HEAD`'s tree against the working tree directly (combined), for a +/// [`ChangesetSource::Uncommitted`] changeset. /// -/// The unstaged side sets `include_untracked`/`recurse_untracked_dirs`/ +/// The unstaged and combined sides both set `include_untracked`/`recurse_untracked_dirs`/ /// `show_untracked_content` so untracked files carry real content in the model (git2 gives -/// `Delta::Untracked` natively here — no `/dev/null` header synthesis needed). +/// `Delta::Untracked` natively here — no `/dev/null` header synthesis needed). `find_similar` +/// runs on all three diffs before materialization so worktree renames (e.g. an untracked file +/// that replaces a tracked one under a new name) surface as [`crate::model::FileStatus::Renamed`] +/// rather than a delete+add pair — the read side already handles that status (corpus-proven). +/// +/// The two untracked-including diffs (unstaged, combined) pass explicit +/// [`DiffFindOptions::for_untracked`] — plain `find_similar(None)`'s default flags (just +/// `GIT_DIFF_FIND_RENAMES`) do NOT pair an untracked file with a workdir deletion; libgit2 +/// requires `for_untracked` opted in separately for that side of the match. The staged diff +/// never sees untracked deltas, so `None` (matching [`diff_committed`]'s convention) is enough +/// there. pub fn diff_uncommitted(repo: &Repository) -> Result { let head_tree = repo.head()?.peel_to_tree()?; let mut staged_opts = DiffOptions::new(); staged_opts.context_lines(3); - let staged_diff = repo.diff_tree_to_index(Some(&head_tree), None, Some(&mut staged_opts))?; + let mut staged_diff = + repo.diff_tree_to_index(Some(&head_tree), None, Some(&mut staged_opts))?; + staged_diff.find_similar(None)?; let staged = DiffModel::from_git2(&staged_diff)?; - let mut unstaged_opts = DiffOptions::new(); - unstaged_opts + let mut worktree_opts = DiffOptions::new(); + worktree_opts .include_untracked(true) .recurse_untracked_dirs(true) .show_untracked_content(true) .context_lines(3); - let unstaged_diff = repo.diff_index_to_workdir(None, Some(&mut unstaged_opts))?; + let mut untracked_find = DiffFindOptions::new(); + untracked_find.renames(true).for_untracked(true); + + let mut unstaged_diff = repo.diff_index_to_workdir(None, Some(&mut worktree_opts))?; + unstaged_diff.find_similar(Some(&mut untracked_find))?; let unstaged = DiffModel::from_git2(&unstaged_diff)?; - Ok(WorktreeDiffs { staged, unstaged }) + let mut combined_diff = + repo.diff_tree_to_workdir_with_index(Some(&head_tree), Some(&mut worktree_opts))?; + combined_diff.find_similar(Some(&mut untracked_find))?; + let combined = DiffModel::from_git2(&combined_diff)?; + + Ok(WorktreeDiffs { + staged, + unstaged, + combined, + }) } /// Diff `base`'s tree against `head`'s tree, for a [`ChangesetSource::Committed`] changeset — diff --git a/git-workon-review/tests/diff_model.rs b/git-workon-review/tests/diff_model.rs index fa77fa2..ac08167 100644 --- a/git-workon-review/tests/diff_model.rs +++ b/git-workon-review/tests/diff_model.rs @@ -343,6 +343,92 @@ fn hunk_to_diff_bytes_matches_diff_print() -> Result<(), Box Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .partially_staged_file( + "f.txt", + "line1\nline2\nline3\n", + "line1\nSTAGED\nline3\n", + "line1\nSTAGED\nWORKDIR\n", + ) + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + // Split views each see only their own half of the change. + assert_eq!(diffs.staged.files.len(), 1); + assert_eq!(diffs.unstaged.files.len(), 1); + + // Combined fuses both onto one file, diffing straight from HEAD to the workdir. + assert_eq!(diffs.combined.files.len(), 1); + let file = &diffs.combined.files[0]; + assert_eq!(file.path, "f.txt"); + assert_eq!(file.status, FileStatus::Modified); + assert_eq!(file.hunks.len(), 1); + let added: Vec<&[u8]> = file.hunks[0] + .lines + .iter() + .filter(|l| l.kind == LineKind::Addition) + .map(|l| l.content.as_slice()) + .collect(); + // Both the staged AND the unstaged edit show up as additions in the one fused hunk. + assert!(added.contains(&b"STAGED\n".as_slice())); + assert!(added.contains(&b"WORKDIR\n".as_slice())); + + Ok(()) +} + +#[test] +fn untracked_file_appears_as_added_in_combined() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "hello\nworld\n") + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + assert_eq!(diffs.combined.files.len(), 1); + let file = &diffs.combined.files[0]; + assert_eq!(file.path, "new.txt"); + // Matches the unstaged side's convention (see `untracked_file_has_full_content_as_addition`): + // git2 reports untracked deltas as `Delta::Untracked`, not `Delta::Added` — all lines are + // still additions since there is no pre-image. + assert_eq!(file.status, FileStatus::Untracked); + assert!(file.hunks[0] + .lines + .iter() + .all(|l| l.kind == LineKind::Addition)); + + Ok(()) +} + +#[test] +fn renamed_in_worktree_file_surfaces_as_renamed_in_combined( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + // Deleted from the working tree, still in HEAD/index... + .deleted_file("old.txt", "line1\nline2\nline3\nline4\nline5\n") + // ...and a same-content untracked file lands under a new name — a worktree rename + // `find_similar` must pair up. + .untracked_file("new.txt", "line1\nline2\nline3\nline4\nline5\n") + .build()?; + let repo = fixture.repo()?; + + let diffs = diff_uncommitted(repo)?; + assert_eq!(diffs.combined.files.len(), 1); + let file = &diffs.combined.files[0]; + assert_eq!(file.status, FileStatus::Renamed); + assert_eq!(file.path, "new.txt"); + assert_eq!(file.old_path.as_deref(), Some("old.txt")); + + Ok(()) +} + // ── diff_changeset over a real assemble_changesets result ───────────────────── #[test] From 27ba5ebf1a2757abd3a580c7b439587c5c059cd8 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 18:27:32 -0400 Subject: [PATCH 021/203] feat(review): port SBS row alignment with collapsed context gaps --- git-workon-review/src/align.rs | 532 +++++++++++++++++++++++++++++++++ git-workon-review/src/lib.rs | 1 + 2 files changed, 533 insertions(+) create mode 100644 git-workon-review/src/align.rs diff --git a/git-workon-review/src/align.rs b/git-workon-review/src/align.rs new file mode 100644 index 0000000..f642181 --- /dev/null +++ b/git-workon-review/src/align.rs @@ -0,0 +1,532 @@ +//! Side-by-side row alignment, ported from the `review-tui-spike` prototype's `align.rs`. +//! +//! Walks a file's hunks against its full old/new text and produces one row vector pairing +//! old-side and new-side positions so the UI can render a row-aligned side-by-side view. +//! Outside hunks, lines pair 1:1. Inside a hunk, git emits deletions before additions within +//! each change block; we pair del[i] with add[i] and give the shorter side filler rows for the +//! excess. +//! +//! This module reads only hunk counters (`old_start`/`old_count`/`new_start`/`new_count`), +//! [`crate::model::Hunk::lines`], and each line's kind + `old_lnum`/`new_lnum`. Content is NOT +//! read from hunk lines here — rendering reads full file text by line number so numbers and +//! content stay in sync (M4 concern; out of scope for this module). +//! +//! ## Lineno invariant +//! +//! [`crate::model::HunkLine::old_lnum`]/`new_lnum` are `None` for the wrong side of an +//! addition/deletion (see the doc comment on [`crate::synthesis::LineSelection`], which relies +//! on the same guarantee). Concretely: a [`LineKind::Context`] line always has both linenos +//! populated; a [`LineKind::Deletion`] line always has `old_lnum` populated; a +//! [`LineKind::Addition`] line always has `new_lnum` populated. This is git2's own guarantee +//! (`Patch::line_in_hunk`'s `old_lineno`/`new_lineno`), not something this module can violate, +//! so the pairing code below `expect()`s the lineno for the side each kind is documented to +//! carry. + +use crate::model::{Hunk, HunkLine, LineKind}; + +/// A row position on one side of the aligned view. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Row { + /// 1-based line number into the full file text for this side. + Line(usize), + Filler, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CellKind { + Context, + Del, + Add, + Filler, +} + +#[derive(Debug, Clone, Copy)] +pub struct AlignedRow { + pub old: Row, + pub new: Row, + pub old_kind: CellKind, + pub new_kind: CellKind, +} + +impl AlignedRow { + /// True when this row is a paired change line (Del on old, Add on new) eligible for + /// word-level diffing. Unpaired excess lines get whole-line emphasis instead. + pub fn is_word_diff_pair(&self) -> bool { + matches!( + (self.old_kind, self.new_kind), + (CellKind::Del, CellKind::Add) + ) + } +} + +pub struct Aligned { + pub rows: Vec, +} + +fn gap_end(start: usize, count: usize) -> usize { + if count == 0 { + start + } else { + start - 1 + } +} + +/// Flush a pending del/add block, pairing by index and emitting filler rows for the excess on +/// the shorter side. +fn flush_block(dels: &[&HunkLine], adds: &[&HunkLine], rows: &mut Vec) { + let max_len = dels.len().max(adds.len()); + for i in 0..max_len { + let (old, old_kind) = match dels.get(i) { + Some(d) => ( + Row::Line(d.old_lnum.expect("deletion line has old_lnum") as usize), + CellKind::Del, + ), + None => (Row::Filler, CellKind::Filler), + }; + let (new, new_kind) = match adds.get(i) { + Some(a) => ( + Row::Line(a.new_lnum.expect("addition line has new_lnum") as usize), + CellKind::Add, + ), + None => (Row::Filler, CellKind::Filler), + }; + rows.push(AlignedRow { + old, + new, + old_kind, + new_kind, + }); + } +} + +/// Align a file's rows given its hunks. `old_line_count` / `new_line_count` are the total line +/// counts of the full old/new text, used to fill the tail gap after the last hunk. +pub fn align_file(hunks: &[Hunk], old_line_count: usize, new_line_count: usize) -> Aligned { + let mut rows = Vec::new(); + let mut old_pos = 0usize; // count of old lines already emitted + let mut new_pos = 0usize; + + for hunk in hunks { + let old_start = hunk.old_start as usize; + let old_count = hunk.old_count as usize; + let new_start = hunk.new_start as usize; + let new_count = hunk.new_count as usize; + + let old_ge = gap_end(old_start, old_count); + let new_ge = gap_end(new_start, new_count); + let old_gap = old_ge.saturating_sub(old_pos); + let new_gap = new_ge.saturating_sub(new_pos); + debug_assert_eq!( + old_gap, new_gap, + "context gap between hunks must be equal length on both sides" + ); + let gap = old_gap.min(new_gap); + for i in 0..gap { + rows.push(AlignedRow { + old: Row::Line(old_pos + i + 1), + new: Row::Line(new_pos + i + 1), + old_kind: CellKind::Context, + new_kind: CellKind::Context, + }); + } + + let mut pending_dels: Vec<&HunkLine> = Vec::new(); + let mut pending_adds: Vec<&HunkLine> = Vec::new(); + for line in &hunk.lines { + match line.kind { + LineKind::Deletion => pending_dels.push(line), + LineKind::Addition => pending_adds.push(line), + LineKind::Context => { + if !pending_dels.is_empty() || !pending_adds.is_empty() { + flush_block(&pending_dels, &pending_adds, &mut rows); + pending_dels.clear(); + pending_adds.clear(); + } + rows.push(AlignedRow { + old: Row::Line(line.old_lnum.expect("context line has old_lnum") as usize), + new: Row::Line(line.new_lnum.expect("context line has new_lnum") as usize), + old_kind: CellKind::Context, + new_kind: CellKind::Context, + }); + } + } + } + if !pending_dels.is_empty() || !pending_adds.is_empty() { + flush_block(&pending_dels, &pending_adds, &mut rows); + } + + old_pos = old_start + old_count.saturating_sub(1); + new_pos = new_start + new_count.saturating_sub(1); + } + + // Tail gap after the last hunk (or the whole file, if there are no hunks). + let old_tail = old_line_count.saturating_sub(old_pos); + let new_tail = new_line_count.saturating_sub(new_pos); + debug_assert_eq!( + old_tail, new_tail, + "trailing context after the last hunk must be equal length on both sides" + ); + let tail = old_tail.min(new_tail); + for i in 0..tail { + rows.push(AlignedRow { + old: Row::Line(old_pos + i + 1), + new: Row::Line(new_pos + i + 1), + old_kind: CellKind::Context, + new_kind: CellKind::Context, + }); + } + + Aligned { rows } +} + +/// A row of the gap-collapsed display, layered over [`AlignedRow`]s. +/// +/// Unchanged stretches longer than `2 * CONTEXT_LINES` collapse to a single [`DisplayRow::Gap`] +/// so the view doesn't scroll through pages of untouched code. Gap rows are layout-agnostic — +/// they span both panes in SBS. +#[derive(Debug, Clone, Copy)] +pub enum DisplayRow { + Row(AlignedRow), + Gap { skipped: usize }, +} + +/// Number of context lines kept around hunk content on each side of a gap. +pub const CONTEXT_LINES: usize = 3; + +/// Collapse long unchanged stretches in `rows` into [`DisplayRow::Gap`] markers, keeping +/// [`CONTEXT_LINES`] rows of context immediately around hunk content (Del/Add/Filler rows). +/// +/// A stretch of context rows collapses only when it is strictly longer than `2 * CONTEXT_LINES` +/// (enough to keep `CONTEXT_LINES` on both sides of the gap); shorter stretches, including ones +/// between two hunks that are close together, are left as-is (no gap row — the hunks +/// effectively merge under one continuous context run). +pub fn collapse_gaps(rows: &[AlignedRow]) -> Vec { + collapse_gaps_with(rows, CONTEXT_LINES) +} + +/// Same as [`collapse_gaps`] but with an explicit context-line count, for testing. +fn collapse_gaps_with(rows: &[AlignedRow], context: usize) -> Vec { + let is_context = |row: &AlignedRow| { + matches!( + (row.old_kind, row.new_kind), + (CellKind::Context, CellKind::Context) + ) + }; + + let mut out = Vec::with_capacity(rows.len()); + let mut i = 0; + while i < rows.len() { + if !is_context(&rows[i]) { + out.push(DisplayRow::Row(rows[i])); + i += 1; + continue; + } + + // Measure the full run of context rows starting at i. + let run_start = i; + let mut run_end = i; + while run_end < rows.len() && is_context(&rows[run_end]) { + run_end += 1; + } + let run_len = run_end - run_start; + + // Keep `context` lines of lead-in unless this run touches the start of the file (no + // hunk before it to lead away from) or the end of the file (no hunk after it to lead + // into) — those edges get no filler on the missing side. + let keep_before = if run_start == 0 { 0 } else { context }; + let keep_after = if run_end == rows.len() { 0 } else { context }; + + if (keep_before == 0 && keep_after == 0) || run_len <= keep_before + keep_after { + // Either too short to collapse, or (keep_before == keep_after == 0) this run is + // the entire row list — a wholly unchanged file with no hunk on either side to + // contextualize. Emit every row, no gap. + for row in &rows[run_start..run_end] { + out.push(DisplayRow::Row(*row)); + } + } else { + for row in &rows[run_start..run_start + keep_before] { + out.push(DisplayRow::Row(*row)); + } + let skipped = run_len - keep_before - keep_after; + out.push(DisplayRow::Gap { skipped }); + for row in &rows[run_end - keep_after..run_end] { + out.push(DisplayRow::Row(*row)); + } + } + + i = run_end; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Hunk, HunkLine, LineKind}; + + fn hl(kind: LineKind, old: Option, new: Option) -> HunkLine { + HunkLine { + kind, + content: Vec::new(), + old_lnum: old, + new_lnum: new, + missing_newline: false, + } + } + + fn hunk( + old_start: u32, + old_count: u32, + new_start: u32, + new_count: u32, + lines: Vec, + ) -> Hunk { + Hunk { + old_start, + old_count, + new_start, + new_count, + header: Vec::new(), + lines, + } + } + + #[test] + fn parity_invariant_holds() { + // 3 dels / 1 add block inside a hunk with context on both sides. + let h = hunk( + 1, + 5, + 1, + 3, + vec![ + hl(LineKind::Context, Some(1), Some(1)), + hl(LineKind::Deletion, Some(2), None), + hl(LineKind::Deletion, Some(3), None), + hl(LineKind::Deletion, Some(4), None), + hl(LineKind::Addition, None, Some(2)), + hl(LineKind::Context, Some(5), Some(3)), + ], + ); + let aligned = align_file(&[h], 5, 3); + for row in &aligned.rows { + assert_eq!( + matches!(row.old, Row::Filler), + row.old_kind == CellKind::Filler + ); + assert_eq!( + matches!(row.new, Row::Filler), + row.new_kind == CellKind::Filler + ); + } + + // ctx1, then 3 paired-or-filler rows for the del/add block, then ctx2. + assert_eq!(aligned.rows.len(), 5); + assert_eq!(aligned.rows[0].old_kind, CellKind::Context); + assert_eq!(aligned.rows[0].new_kind, CellKind::Context); + + // del1/add1 paired. + assert_eq!(aligned.rows[1].old, Row::Line(2)); + assert_eq!(aligned.rows[1].new, Row::Line(2)); + assert_eq!(aligned.rows[1].old_kind, CellKind::Del); + assert_eq!(aligned.rows[1].new_kind, CellKind::Add); + assert!(aligned.rows[1].is_word_diff_pair()); + + // del2/del3 have no add counterpart -> filler on new side. + assert_eq!(aligned.rows[2].old, Row::Line(3)); + assert_eq!(aligned.rows[2].new, Row::Filler); + assert_eq!(aligned.rows[2].new_kind, CellKind::Filler); + assert!(!aligned.rows[2].is_word_diff_pair()); + + assert_eq!(aligned.rows[3].old, Row::Line(4)); + assert_eq!(aligned.rows[3].new, Row::Filler); + + assert_eq!(aligned.rows[4].old_kind, CellKind::Context); + assert_eq!(aligned.rows[4].old, Row::Line(5)); + assert_eq!(aligned.rows[4].new, Row::Line(3)); + } + + #[test] + fn pure_addition_at_start_of_file() { + let h = hunk( + 0, + 0, + 1, + 2, + vec![ + hl(LineKind::Addition, None, Some(1)), + hl(LineKind::Addition, None, Some(2)), + ], + ); + let aligned = align_file(&[h], 0, 2); + assert_eq!(aligned.rows.len(), 2); + assert_eq!(aligned.rows[0].old, Row::Filler); + assert_eq!(aligned.rows[0].new, Row::Line(1)); + assert_eq!(aligned.rows[1].old, Row::Filler); + assert_eq!(aligned.rows[1].new, Row::Line(2)); + } + + #[test] + fn no_hunks_pairs_whole_file_1to1() { + let aligned = align_file(&[], 4, 4); + assert_eq!(aligned.rows.len(), 4); + for (i, row) in aligned.rows.iter().enumerate() { + assert_eq!(row.old, Row::Line(i + 1)); + assert_eq!(row.new, Row::Line(i + 1)); + assert_eq!(row.old_kind, CellKind::Context); + } + } + + fn context_row(n: usize) -> AlignedRow { + AlignedRow { + old: Row::Line(n), + new: Row::Line(n), + old_kind: CellKind::Context, + new_kind: CellKind::Context, + } + } + + fn change_row(old: Row, new: Row, old_kind: CellKind, new_kind: CellKind) -> AlignedRow { + AlignedRow { + old, + new, + old_kind, + new_kind, + } + } + + #[test] + fn tiny_file_produces_no_gaps() { + // Whole file is context, shorter than 2 * context: no gap. + let rows: Vec = (1..=4).map(context_row).collect(); + let display = collapse_gaps_with(&rows, 3); + assert_eq!(display.len(), 4); + assert!(display.iter().all(|r| matches!(r, DisplayRow::Row(_)))); + } + + #[test] + fn gap_between_hunks_collapses_middle() { + // hunk1 change, 10 lines context, hunk2 change: with context=3, the middle 4 lines + // (10 - 3 - 3) collapse into one gap row. + let mut rows = vec![change_row( + Row::Line(1), + Row::Line(1), + CellKind::Del, + CellKind::Add, + )]; + rows.extend((2..=11).map(context_row)); + rows.push(change_row( + Row::Line(12), + Row::Line(12), + CellKind::Del, + CellKind::Add, + )); + + let display = collapse_gaps_with(&rows, 3); + // change, 3 ctx, gap, 3 ctx, change + assert_eq!(display.len(), 9); + assert!(matches!(display[0], DisplayRow::Row(_))); + for row in &display[1..4] { + assert!(matches!(row, DisplayRow::Row(r) if r.old_kind == CellKind::Context)); + } + match display[4] { + DisplayRow::Gap { skipped } => assert_eq!(skipped, 4), + other => panic!("expected gap row, got {other:?}"), + } + for row in &display[5..8] { + assert!(matches!(row, DisplayRow::Row(r) if r.old_kind == CellKind::Context)); + } + assert!(matches!(display[8], DisplayRow::Row(_))); + + // The gap hides the same count on both sides by construction (rows are already + // parity-paired context lines), but assert explicitly on the surviving rows' + // continuity: line just before the gap and line just after are the expected distance + // apart on both old and new sides. + if let (DisplayRow::Row(before), DisplayRow::Row(after)) = (display[3], display[5]) { + let (Row::Line(before_old), Row::Line(before_new)) = (before.old, before.new) else { + panic!("expected line rows around the gap"); + }; + // after is the next change row (old=12,new=12); the gap plus kept context must + // account for all lines strictly between. + let (Row::Line(after_old), Row::Line(after_new)) = (after.old, after.new) else { + panic!("expected line rows around the gap"); + }; + assert_eq!( + after_old - before_old, + after_new - before_new, + "gap hides equal spans" + ); + } + } + + #[test] + fn adjacent_hunks_with_too_little_context_merge_without_gap() { + // Only 4 lines of context between two change blocks with context=3: 4 <= 3+3, no gap. + let mut rows = vec![change_row( + Row::Line(1), + Row::Line(1), + CellKind::Del, + CellKind::Add, + )]; + rows.extend((2..=5).map(context_row)); + rows.push(change_row( + Row::Line(6), + Row::Line(6), + CellKind::Del, + CellKind::Add, + )); + + let display = collapse_gaps_with(&rows, 3); + assert_eq!(display.len(), rows.len()); + assert!(display.iter().all(|r| matches!(r, DisplayRow::Row(_)))); + } + + #[test] + fn gap_at_file_start_has_no_lead_in() { + // Leading context run (file starts unchanged) before the first hunk: no context to + // "lead away from" on the left edge, so the whole run before the trailing keep-window + // can collapse. + let mut rows: Vec = (1..=10).map(context_row).collect(); + rows.push(change_row( + Row::Line(11), + Row::Line(11), + CellKind::Del, + CellKind::Add, + )); + + let display = collapse_gaps_with(&rows, 3); + // gap, 3 ctx, change + assert_eq!(display.len(), 5); + match display[0] { + DisplayRow::Gap { skipped } => assert_eq!(skipped, 7), + other => panic!("expected gap row, got {other:?}"), + } + for row in &display[1..4] { + assert!(matches!(row, DisplayRow::Row(_))); + } + assert!(matches!(display[4], DisplayRow::Row(_))); + } + + #[test] + fn gap_at_file_end_has_no_trail_out() { + let mut rows = vec![change_row( + Row::Line(1), + Row::Line(1), + CellKind::Del, + CellKind::Add, + )]; + rows.extend((2..=11).map(context_row)); + + let display = collapse_gaps_with(&rows, 3); + // change, 3 ctx, gap + assert_eq!(display.len(), 5); + assert!(matches!(display[0], DisplayRow::Row(_))); + for row in &display[1..4] { + assert!(matches!(row, DisplayRow::Row(_))); + } + match display[4] { + DisplayRow::Gap { skipped } => assert_eq!(skipped, 7), + other => panic!("expected gap row, got {other:?}"), + } + } +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index f3aa6f6..5ac59e5 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -13,6 +13,7 @@ //! verdict corpus lands in the next M2 changeset. pub mod acquire; +pub mod align; pub mod apply; pub mod error; pub mod file_ops; From a8dbb27af91d348687cacac17af93b87da78a602 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 18:39:30 -0400 Subject: [PATCH 022/203] feat(review): port word-diff spans and tree-sitter highlighting --- Cargo.lock | 77 ++++++ Cargo.toml | 7 + git-workon-review/Cargo.toml | 13 +- git-workon-review/src/highlight.rs | 397 +++++++++++++++++++++++++++++ git-workon-review/src/lib.rs | 2 + git-workon-review/src/wordiff.rs | 91 +++++++ 6 files changed, 584 insertions(+), 3 deletions(-) create mode 100644 git-workon-review/src/highlight.rs create mode 100644 git-workon-review/src/wordiff.rs diff --git a/Cargo.lock b/Cargo.lock index 52f9a2e..454d9ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -967,10 +967,17 @@ dependencies = [ "miette", "predicates", "ratatui", + "similar", "thiserror 2.0.19", "tree-sitter", "tree-sitter-highlight", + "tree-sitter-javascript", + "tree-sitter-json", + "tree-sitter-lua", + "tree-sitter-md", "tree-sitter-rust", + "tree-sitter-toml-ng", + "tree-sitter-typescript", ] [[package]] @@ -2355,6 +2362,15 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6505efef05804732ed8a3f2d4f279429eb485bd69d5b0cc6b19cc02005cda16" +dependencies = [ + "bstr", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -2727,12 +2743,53 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-language" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +[[package]] +name = "tree-sitter-lua" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8daaf5f4235188a58603c39760d5fa5d4b920d36a299c934adddae757f32a10c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-md" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882" +dependencies = [ + "cc", + "tree-sitter", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-rust" version = "0.24.2" @@ -2743,6 +2800,26 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-toml-ng" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "typenum" version = "1.20.1" diff --git a/Cargo.toml b/Cargo.toml index bb022c9..6ece5ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,10 +49,17 @@ ratatui = "0.30" rusqlite = { version = "0.40", features = ["bundled"] } serde_json = "1.0" serial_test = "3" +similar = "3.1.1" thiserror = "2.0.18" tree-sitter = "0.26" tree-sitter-highlight = "0.26" +tree-sitter-javascript = "0.25.0" +tree-sitter-json = "0.24.8" +tree-sitter-lua = "0.5.0" +tree-sitter-md = { version = "0.5.3", features = ["parser"] } tree-sitter-rust = "0.24" +tree-sitter-toml-ng = "0.7.0" +tree-sitter-typescript = "0.23.2" unicode-width = "0.2.2" # The profile that 'dist' will build with diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index 2f9c731..5de95e6 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -37,7 +37,17 @@ git-workon-lib.workspace = true git2.workspace = true miette.workspace = true ratatui.workspace = true +similar.workspace = true thiserror.workspace = true +tree-sitter.workspace = true +tree-sitter-highlight.workspace = true +tree-sitter-javascript.workspace = true +tree-sitter-json.workspace = true +tree-sitter-lua.workspace = true +tree-sitter-md.workspace = true +tree-sitter-rust.workspace = true +tree-sitter-toml-ng.workspace = true +tree-sitter-typescript.workspace = true [package.metadata.dist] # Redundant with publish = false today; load-bearing at the M3 flip so @@ -48,6 +58,3 @@ dist = false assert_cmd.workspace = true git-workon-fixture.workspace = true predicates.workspace = true -tree-sitter.workspace = true -tree-sitter-highlight.workspace = true -tree-sitter-rust.workspace = true diff --git a/git-workon-review/src/highlight.rs b/git-workon-review/src/highlight.rs new file mode 100644 index 0000000..403d86e --- /dev/null +++ b/git-workon-review/src/highlight.rs @@ -0,0 +1,397 @@ +//! Syntax highlighting via tree-sitter. +//! +//! One `HighlightConfiguration` is built lazily per language and cached. +//! Highlight events give byte offsets over the whole source; we split them +//! into per-line spans here so the renderer can compose them against +//! word-diff spans without re-deriving line boundaries. + +use std::collections::HashMap; + +use ratatui::style::Color; +use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter}; + +/// Files with more lines than this are skipped (plain fg) to keep +/// highlighting fast. +pub const MAX_HIGHLIGHT_LINES: usize = 20_000; + +/// Foreground color spans for a single line: byte range + color. +#[derive(Debug, Clone)] +pub struct FgSpan { + pub start: usize, + pub end: usize, + pub color: Color, +} + +/// The standard highlight-capture names we recognize. `configure()` matches +/// dotted capture names by longest prefix, so e.g. `keyword.control` maps to +/// `keyword`. Parallel with `HIGHLIGHT_COLORS`. +const HIGHLIGHT_NAMES: &[&str] = &[ + "attribute", + "comment", + "constant", + "constant.builtin", + "constructor", + "embedded", + "escape", + "function", + "function.builtin", + "function.macro", + "function.method", + "keyword", + "label", + "number", + "operator", + "property", + "punctuation", + "punctuation.bracket", + "punctuation.delimiter", + "punctuation.special", + "string", + "string.special", + "tag", + "type", + "type.builtin", + "variable", + "variable.builtin", + "variable.parameter", +]; + +// A small dark theme in the same family as syntect's base16-eighties.dark so +// the two engines look comparable side by side. +const C_RED: Color = Color::Rgb(0xf2, 0x77, 0x7a); +const C_ORANGE: Color = Color::Rgb(0xf9, 0x91, 0x57); +const C_YELLOW: Color = Color::Rgb(0xff, 0xcc, 0x66); +const C_GREEN: Color = Color::Rgb(0x99, 0xcc, 0x99); +const C_CYAN: Color = Color::Rgb(0x66, 0xcc, 0xcc); +const C_BLUE: Color = Color::Rgb(0x66, 0x99, 0xcc); +const C_PURPLE: Color = Color::Rgb(0xcc, 0x99, 0xcc); +const C_FG: Color = Color::Rgb(0xd3, 0xd0, 0xc8); +const C_COMMENT: Color = Color::Rgb(0x74, 0x73, 0x69); + +const HIGHLIGHT_COLORS: &[Color] = &[ + C_ORANGE, // attribute + C_COMMENT, // comment + C_ORANGE, // constant + C_ORANGE, // constant.builtin + C_YELLOW, // constructor + C_FG, // embedded + C_CYAN, // escape + C_BLUE, // function + C_BLUE, // function.builtin + C_BLUE, // function.macro + C_BLUE, // function.method + C_PURPLE, // keyword + C_RED, // label + C_ORANGE, // number + C_FG, // operator + C_CYAN, // property + C_FG, // punctuation + C_FG, // punctuation.bracket + C_FG, // punctuation.delimiter + C_CYAN, // punctuation.special + C_GREEN, // string + C_CYAN, // string.special + C_RED, // tag + C_YELLOW, // type + C_YELLOW, // type.builtin + C_FG, // variable + C_RED, // variable.builtin + C_FG, // variable.parameter +]; + +/// Color for a highlight-capture name, for tests and debugging. +#[cfg(test)] +pub fn color_of(name: &str) -> Option { + HIGHLIGHT_NAMES + .iter() + .position(|n| *n == name) + .map(|i| HIGHLIGHT_COLORS[i]) +} + +fn lang_key_for_ext(ext: &str) -> Option<&'static str> { + match ext { + "rs" => Some("rust"), + "lua" => Some("lua"), + "json" => Some("json"), + "toml" => Some("toml"), + "js" | "mjs" | "cjs" | "jsx" => Some("javascript"), + "ts" | "mts" | "cts" => Some("typescript"), + "tsx" => Some("tsx"), + "md" | "markdown" => Some("markdown"), + _ => None, + } +} + +fn build_config(key: &'static str) -> Option { + let result = match key { + "rust" => HighlightConfiguration::new( + tree_sitter_rust::LANGUAGE.into(), + "rust", + tree_sitter_rust::HIGHLIGHTS_QUERY, + tree_sitter_rust::INJECTIONS_QUERY, + "", + ), + "lua" => HighlightConfiguration::new( + tree_sitter_lua::LANGUAGE.into(), + "lua", + tree_sitter_lua::HIGHLIGHTS_QUERY, + tree_sitter_lua::INJECTIONS_QUERY, + tree_sitter_lua::LOCALS_QUERY, + ), + "json" => HighlightConfiguration::new( + tree_sitter_json::LANGUAGE.into(), + "json", + tree_sitter_json::HIGHLIGHTS_QUERY, + "", + "", + ), + "toml" => HighlightConfiguration::new( + tree_sitter_toml_ng::LANGUAGE.into(), + "toml", + tree_sitter_toml_ng::HIGHLIGHTS_QUERY, + "", + "", + ), + "javascript" => { + // The JS grammar includes JSX nodes, so the JSX query is safe to + // append for plain .js too. + let highlights = format!( + "{}{}", + tree_sitter_javascript::HIGHLIGHT_QUERY, + tree_sitter_javascript::JSX_HIGHLIGHT_QUERY + ); + HighlightConfiguration::new( + tree_sitter_javascript::LANGUAGE.into(), + "javascript", + &highlights, + tree_sitter_javascript::INJECTIONS_QUERY, + tree_sitter_javascript::LOCALS_QUERY, + ) + } + "typescript" => { + // tree-sitter-highlight gives precedence to the LAST matching pattern, so the + // inherited javascript query goes first and the language-specific query is + // appended (wins on conflicts). + let highlights = format!( + "{}{}", + tree_sitter_javascript::HIGHLIGHT_QUERY, + tree_sitter_typescript::HIGHLIGHTS_QUERY + ); + HighlightConfiguration::new( + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + "typescript", + &highlights, + "", + tree_sitter_typescript::LOCALS_QUERY, + ) + } + "tsx" => { + // Same last-wins precedence as the typescript arm above. + let highlights = format!( + "{}{}{}", + tree_sitter_javascript::HIGHLIGHT_QUERY, + tree_sitter_javascript::JSX_HIGHLIGHT_QUERY, + tree_sitter_typescript::HIGHLIGHTS_QUERY + ); + HighlightConfiguration::new( + tree_sitter_typescript::LANGUAGE_TSX.into(), + "tsx", + &highlights, + "", + tree_sitter_typescript::LOCALS_QUERY, + ) + } + "markdown" => HighlightConfiguration::new( + tree_sitter_md::LANGUAGE.into(), + "markdown", + tree_sitter_md::HIGHLIGHT_QUERY_BLOCK, + "", + "", + ), + _ => return None, + }; + + match result { + Ok(mut config) => { + config.configure(HIGHLIGHT_NAMES); + Some(config) + } + Err(_) => None, + } +} + +pub struct TsHighlighter { + core: Highlighter, + /// Lazily built configs; `None` records a failed build so we don't retry. + configs: HashMap<&'static str, Option>, +} + +impl TsHighlighter { + pub fn new() -> Self { + Self { + core: Highlighter::new(), + configs: HashMap::new(), + } + } + + /// Highlight the full text of a file, returning one Vec per line. + /// `None` means: no grammar for this extension, file too large, or a + /// highlight error — caller should fall back to unhighlighted text. + pub fn highlight_file(&mut self, path: &str, text: &str) -> Option>> { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + let key = lang_key_for_ext(ext)?; + + let line_count = text.lines().count(); + if line_count > MAX_HIGHLIGHT_LINES { + return None; + } + + let config = self + .configs + .entry(key) + .or_insert_with(|| build_config(key)) + .as_ref()?; + + // Byte offset of each line start; used to split whole-source spans + // into per-line spans. + let mut line_starts = vec![0usize]; + for (i, b) in text.bytes().enumerate() { + if b == b'\n' { + line_starts.push(i + 1); + } + } + + let mut out: Vec> = vec![Vec::new(); line_count]; + let mut stack: Vec = Vec::new(); + + let events = self + .core + .highlight(config, text.as_bytes(), None, |_| None) + .ok()?; + + for event in events { + match event.ok()? { + HighlightEvent::HighlightStart(h) => stack.push(h.0), + HighlightEvent::HighlightEnd => { + stack.pop(); + } + HighlightEvent::Source { start, end } => { + let Some(&idx) = stack.last() else { continue }; + let color = HIGHLIGHT_COLORS[idx]; + let mut pos = start; + while pos < end { + let line_idx = line_starts.partition_point(|&s| s <= pos) - 1; + if line_idx >= line_count { + break; + } + let line_start = line_starts[line_idx]; + // End of line content, excluding the trailing '\n'. + let line_end = line_starts + .get(line_idx + 1) + .map(|s| s - 1) + .unwrap_or(text.len()); + let seg_end = end.min(line_end); + if pos < seg_end { + out[line_idx].push(FgSpan { + start: pos - line_start, + end: seg_end - line_start, + color, + }); + } + pos = match line_starts.get(line_idx + 1) { + Some(&next) => next, + None => end, + }; + } + } + } + } + + Some(out) + } +} + +impl Default for TsHighlighter { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_and_colors_are_parallel() { + assert_eq!(HIGHLIGHT_NAMES.len(), HIGHLIGHT_COLORS.len()); + } + + #[test] + fn rust_snippet_yields_expected_span_kinds_on_right_lines() { + let src = "fn main() {\n let s = \"hi\";\n}\n"; + let mut ts = TsHighlighter::new(); + let hl = ts + .highlight_file("test.rs", src) + .expect("rust grammar available"); + assert_eq!(hl.len(), 3); + + // Line 0: `fn` at bytes 0..2 should be keyword-colored. + let kw = color_of("keyword").unwrap(); + assert!( + hl[0] + .iter() + .any(|s| s.start == 0 && s.end >= 2 && s.color == kw), + "expected keyword span over `fn` on line 0, got {:?}", + hl[0] + ); + + // Line 0: `main` should be function-colored. + let func = color_of("function").unwrap(); + assert!( + hl[0] + .iter() + .any(|s| { s.color == func && &src[..11][s.start..s.end.min(11)] == "main" }), + "expected function span over `main` on line 0, got {:?}", + hl[0] + ); + + // Line 1: string literal should be string-colored. + let string = color_of("string").unwrap(); + assert!( + hl[1].iter().any(|s| s.color == string), + "expected string span on line 1, got {:?}", + hl[1] + ); + } + + #[test] + fn unknown_extension_returns_none() { + let mut ts = TsHighlighter::new(); + assert!(ts.highlight_file("mystery.zzz", "hello world\n").is_none()); + assert!(ts.highlight_file("no_extension", "hello world\n").is_none()); + } + + #[test] + fn spans_never_cross_line_boundaries() { + let src = "/* a\nmultiline\ncomment */\n"; + let mut ts = TsHighlighter::new(); + let hl = ts.highlight_file("c.rs", src).unwrap(); + let line_lens: Vec = src.lines().map(|l| l.len()).collect(); + for (i, spans) in hl.iter().enumerate() { + for s in spans { + assert!(s.end <= line_lens[i], "span {s:?} exceeds line {i} length"); + } + } + // The multiline comment should produce comment spans on all 3 lines. + let comment = color_of("comment").unwrap(); + for (i, spans) in hl.iter().enumerate() { + assert!( + spans.iter().any(|s| s.color == comment), + "expected comment span on line {i}" + ); + } + } +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 5ac59e5..9ef8365 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -17,8 +17,10 @@ pub mod align; pub mod apply; pub mod error; pub mod file_ops; +pub mod highlight; pub mod model; pub mod ops; pub mod queue; pub mod refresh; pub mod synthesis; +pub mod wordiff; diff --git a/git-workon-review/src/wordiff.rs b/git-workon-review/src/wordiff.rs new file mode 100644 index 0000000..5eb0d47 --- /dev/null +++ b/git-workon-review/src/wordiff.rs @@ -0,0 +1,91 @@ +//! Word-level diff spans for a paired del/add line. + +use similar::{ChangeTag, TextDiff}; + +/// A byte range `[start, end)` into a line's text that should be rendered +/// with emphasized ("strong") diff background. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Span { + pub start: usize, + pub end: usize, +} + +/// Compute word-granularity change spans for a paired old/new line. Returns +/// (old_spans, new_spans): byte ranges into `old_text` / `new_text` that +/// differ at word granularity. +pub fn word_diff_spans(old_text: &str, new_text: &str) -> (Vec, Vec) { + let diff = TextDiff::configure().diff_words(old_text, new_text); + + let mut old_spans = Vec::new(); + let mut new_spans = Vec::new(); + let mut old_pos = 0usize; + let mut new_pos = 0usize; + + for change in diff.iter_all_changes() { + let len = change.value().len(); + match change.tag() { + ChangeTag::Equal => { + old_pos += len; + new_pos += len; + } + ChangeTag::Delete => { + old_spans.push(Span { + start: old_pos, + end: old_pos + len, + }); + old_pos += len; + } + ChangeTag::Insert => { + new_spans.push(Span { + start: new_pos, + end: new_pos + len, + }); + new_pos += len; + } + } + } + + (merge_adjacent(old_spans), merge_adjacent(new_spans)) +} + +/// Merge spans that are directly adjacent (no gap) to reduce fragmentation +/// from word-boundary splitting. +fn merge_adjacent(mut spans: Vec) -> Vec { + spans.sort_by_key(|s| s.start); + let mut out: Vec = Vec::with_capacity(spans.len()); + for span in spans { + if let Some(last) = out.last_mut() { + if last.end == span.start { + last.end = span.end; + continue; + } + } + out.push(span); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_changed_word() { + let (old_spans, new_spans) = word_diff_spans("let x = 1;", "let x = 10;"); + assert!(!old_spans.is_empty()); + assert!(!new_spans.is_empty()); + let old_changed = &old_spans[0]; + let old_text = "let x = 1;"; + assert!(old_text[old_changed.start..old_changed.end].contains('1')); + let new_changed = &new_spans[0]; + let new_text = "let x = 10;"; + assert!(new_text[new_changed.start..new_changed.end].contains("10")); + } + + #[test] + fn identical_lines_have_no_spans() { + let (old_spans, new_spans) = word_diff_spans("same line", "same line"); + assert!(old_spans.is_empty()); + assert!(new_spans.is_empty()); + } +} From 81312cfeba1a93ff6538d7b181e852d8fa095d57 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 18:53:10 -0400 Subject: [PATCH 023/203] feat(review): render side-by-side diff frames with highlights --- git-workon-review/src/app.rs | 447 +++++++++++++++++++++++++++ git-workon-review/src/lib.rs | 2 + git-workon-review/src/render.rs | 522 ++++++++++++++++++++++++++++++++ 3 files changed, 971 insertions(+) create mode 100644 git-workon-review/src/app.rs create mode 100644 git-workon-review/src/render.rs diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs new file mode 100644 index 0000000..510daac --- /dev/null +++ b/git-workon-review/src/app.rs @@ -0,0 +1,447 @@ +//! App state: the file list being reviewed, per-file view data (full text + alignment + +//! highlight cache + word-diff cache), and navigation/scroll state. +//! +//! Ported from the `review-tui-spike` prototype's `model.rs` — renamed here because `model` +//! already means the diff model in this crate (see the M3 plan's naming rule). +//! +//! Renders the **combined** (`HEAD` ↔ worktree) diff only (locked design decision #2 in the M3 +//! plan) — the staged/unstaged split zoom is M4. [`App`] owns its own [`git2::Repository`] +//! handle so it can lazily read blob/worktree content per file as the user navigates to it, +//! independent of whatever handle acquired the [`DiffModel`] it was built from. + +use std::collections::HashMap; +use std::path::Path; + +use git2::Repository; + +use crate::align::{align_file, collapse_gaps, CellKind, DisplayRow, Row}; +use crate::highlight::{FgSpan, TsHighlighter}; +use crate::model::{DiffModel, FileChange, FileStatus}; +use crate::wordiff::{word_diff_spans, Span}; + +/// Loaded, aligned, highlighted view of one file's combined diff. +/// +/// Full text is read once per side, from whichever source the file's status says still exists: +/// +/// | status | old-side source | new-side source | +/// |-----------------------|-------------------------------------|-----------------------------| +/// | Added / Untracked | none (empty) | worktree file on disk | +/// | Deleted | `HEAD` blob at `path` | none (empty) | +/// | Renamed / Copied | `HEAD` blob at `old_path` | worktree file at `path` | +/// | Modified / Unmerged | `HEAD` blob at `path` | worktree file at `path` | +/// +/// The new side reads from the **worktree file on disk**, not the index blob — unstaged +/// content isn't in the object database; reading the staged (index) blob is an M4 concern (the +/// staged/unstaged split zoom). +pub struct FileView { + old_text: String, + new_text: String, + old_lines: Vec, + new_lines: Vec, + /// The gap-collapsed row list the renderer walks. Word-diff spans and scroll coordinates + /// are indexed against THIS vector, not the pre-collapse `AlignedRow` vector — collapsing + /// only removes uninteresting context, so the underlying [`Row`]/[`CellKind`] pairing for + /// any surviving row is unchanged. + pub display: Vec, + /// Index into [`Self::display`] of the first hunk's first row (or 0 for a file with no + /// hunks), for the initial scroll jump. + pub first_hunk_row: usize, + pub old_hl: Option>>, + pub new_hl: Option>>, + /// Lazily computed word-diff spans, keyed by DISPLAY row index — the only coordinate the + /// renderer's viewport walks once gaps are collapsed. + word_spans: HashMap, Vec)>, +} + +impl FileView { + fn load( + repo: &Repository, + head_tree: &git2::Tree<'_>, + file: &FileChange, + ts: &mut TsHighlighter, + ) -> Self { + let old_source_path = file.old_path.as_deref().unwrap_or(file.path.as_str()); + let old_text = match file.status { + FileStatus::Added | FileStatus::Untracked => String::new(), + _ => read_head_blob(repo, head_tree, old_source_path), + }; + + let new_text = match file.status { + FileStatus::Deleted => String::new(), + _ => read_workdir_file(repo, &file.path), + }; + + let old_lines: Vec = old_text.lines().map(str::to_string).collect(); + let new_lines: Vec = new_text.lines().map(str::to_string).collect(); + + let aligned = align_file(&file.hunks, old_lines.len(), new_lines.len()); + let display = collapse_gaps(&aligned.rows); + let first_hunk_row = display + .iter() + .position(|row| { + matches!( + row, + DisplayRow::Row(r) if !(r.old_kind == CellKind::Context && r.new_kind == CellKind::Context) + ) + }) + .unwrap_or(0); + + let old_hl = ts.highlight_file(old_source_path, &old_text); + let new_hl = ts.highlight_file(&file.path, &new_text); + + Self { + old_text, + new_text, + old_lines, + new_lines, + display, + first_hunk_row, + old_hl, + new_hl, + word_spans: HashMap::new(), + } + } + + pub fn old_line(&self, n: usize) -> &str { + self.old_lines + .get(n.saturating_sub(1)) + .map(String::as_str) + .unwrap_or("") + } + + pub fn new_line(&self, n: usize) -> &str { + self.new_lines + .get(n.saturating_sub(1)) + .map(String::as_str) + .unwrap_or("") + } + + pub fn old_line_count(&self) -> usize { + self.old_lines.len() + } + + pub fn new_line_count(&self) -> usize { + self.new_lines.len() + } + + /// Full text loaded for the old/new side, for callers that need the whole blob rather than + /// line-by-line access (e.g. re-running highlighting at a different width is NOT needed + /// today, but tests assert against this directly). + pub fn old_text(&self) -> &str { + &self.old_text + } + + pub fn new_text(&self) -> &str { + &self.new_text + } + + /// Lazily compute (and cache) word-diff spans for a paired display row. Returns empty spans + /// (and does not populate the cache) for a row that isn't a `(Del, Add)` pair — callers + /// check [`crate::align::AlignedRow::is_word_diff_pair`] first in the common case, but this + /// stays total so it's safe to call unconditionally. + pub fn word_spans_for_row(&mut self, display_idx: usize) -> (Vec, Vec) { + if let Some(cached) = self.word_spans.get(&display_idx) { + return cached.clone(); + } + let pair = match self.display.get(display_idx) { + Some(DisplayRow::Row(row)) if row.is_word_diff_pair() => Some((row.old, row.new)), + _ => None, + }; + match pair { + Some((Row::Line(o), Row::Line(n))) => { + let spans = word_diff_spans(self.old_line(o), self.new_line(n)); + self.word_spans.insert(display_idx, spans.clone()); + spans + } + _ => (Vec::new(), Vec::new()), + } + } + + /// Read-only peek at an already-cached word-diff span pair (empty if uncached). Used by the + /// renderer's second (immutable) pass after a first mutable pass has populated the cache + /// for the visible viewport via [`Self::word_spans_for_row`]. + pub fn peek_word_spans(&self, display_idx: usize) -> (Vec, Vec) { + self.word_spans + .get(&display_idx) + .cloned() + .unwrap_or_default() + } +} + +fn read_head_blob(repo: &Repository, tree: &git2::Tree<'_>, path: &str) -> String { + tree.get_path(Path::new(path)) + .and_then(|entry| entry.to_object(repo)) + .ok() + .and_then(|obj| obj.into_blob().ok()) + .map(|blob| String::from_utf8_lossy(blob.content()).into_owned()) + .unwrap_or_default() +} + +fn read_workdir_file(repo: &Repository, path: &str) -> String { + repo.workdir() + .map(|wd| wd.join(path)) + .and_then(|p| std::fs::read(p).ok()) + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) + .unwrap_or_default() +} + +/// Review session state: the combined diff's file list, per-file lazily loaded views, and +/// navigation/scroll state. One long-lived [`TsHighlighter`] lives here (not per file) — its +/// language-config cache is keyed per-instance, so a fresh highlighter per file would rebuild +/// every grammar config on every navigation. +pub struct App { + repo: Repository, + /// The combined diff's files. git2 enumerates these in path order (verified in + /// `tests`), so "current file index" is a stable alphabetical position, not an + /// arrival/discovery order that could reshuffle under the user. + pub files: Vec, + views: Vec>, + pub current: usize, + pub scroll: usize, + pub pane_height: usize, + /// Label for the old side of the diff, shown next to a rename's `old_path` in the header. + /// M3 only reviews the combined (`HEAD` ↔ worktree) diff, so this is always `"HEAD"` today; + /// M4's committed-changeset zoom will want to set this to the changeset's actual base rev. + pub base_label: String, + highlighter: TsHighlighter, +} + +impl App { + pub fn new(repo: Repository, combined: DiffModel) -> Self { + let n = combined.files.len(); + Self { + repo, + files: combined.files, + views: (0..n).map(|_| None).collect(), + current: 0, + scroll: 0, + pane_height: 20, + base_label: "HEAD".to_string(), + highlighter: TsHighlighter::new(), + } + } + + /// Load (and cache) the [`FileView`] for `idx`, unless the file is binary — binary files + /// skip content loading entirely (no blob read, no worktree read, no highlighting): there + /// is nothing for the SBS renderer to align, so [`crate::render`] shows a placeholder + /// without ever calling this. + pub fn ensure_loaded(&mut self, idx: usize) { + let Some(file) = self.files.get(idx) else { + return; + }; + if file.is_binary { + return; + } + if self.views[idx].is_none() { + // Re-peeled per call rather than cached on `App`: HEAD can move between file loads + // (a fine risk in M3's read-only TUI) and the tree is cheap to re-peel. + let Ok(head_tree) = self.repo.head().and_then(|h| h.peel_to_tree()) else { + return; + }; + let view = FileView::load( + &self.repo, + &head_tree, + &self.files[idx], + &mut self.highlighter, + ); + self.views[idx] = Some(view); + } + } + + pub fn current_view(&mut self) -> Option<&mut FileView> { + self.ensure_loaded(self.current); + self.views.get_mut(self.current).and_then(|v| v.as_mut()) + } + + pub fn current_view_ref(&self) -> Option<&FileView> { + self.views.get(self.current).and_then(|v| v.as_ref()) + } + + /// Jump the scroll position to the current file's first hunk (or the top, for a file with + /// no hunks or that isn't loaded yet). + pub fn jump_to_first_hunk(&mut self) { + self.scroll = self + .views + .get(self.current) + .and_then(|v| v.as_ref()) + .map(|v| v.first_hunk_row) + .unwrap_or(0); + } + + /// Load the current file (if not binary) and jump to its first hunk. + pub fn open_current(&mut self) { + self.ensure_loaded(self.current); + self.jump_to_first_hunk(); + } + + pub fn next_file(&mut self) { + if self.files.is_empty() { + return; + } + self.current = (self.current + 1) % self.files.len(); + self.open_current(); + } + + pub fn prev_file(&mut self) { + if self.files.is_empty() { + return; + } + self.current = (self.current + self.files.len() - 1) % self.files.len(); + self.open_current(); + } + + fn row_count(&self) -> usize { + self.current_view_ref() + .map(|v| v.display.len()) + .unwrap_or(0) + } + + fn max_scroll(&self) -> usize { + self.row_count().saturating_sub(self.pane_height.max(1)) + } + + pub fn scroll_by(&mut self, delta: i64) { + let max = self.max_scroll(); + let cur = self.scroll as i64; + let next = (cur + delta).clamp(0, max as i64); + self.scroll = next as usize; + } + + pub fn scroll_top(&mut self) { + self.scroll = 0; + } + + pub fn scroll_bottom(&mut self) { + self.scroll = self.max_scroll(); + } +} + +/// Test-only helper for building an [`App`] straight from a fixture, shared by `app.rs`'s own +/// tests and `render.rs`'s frame tests. `App` owns its `Repository` handle, but +/// [`git_workon_fixture::fixture::Fixture::repo`] only lends a borrowed one — so this opens a +/// second, independent handle on the same workdir. +#[cfg(test)] +pub(crate) mod test_support { + use git2::Repository; + use git_workon_fixture::fixture::Fixture; + + use super::App; + use crate::acquire::diff_uncommitted; + + pub(crate) fn app_from_fixture(fixture: &Fixture) -> App { + let repo = fixture.repo().expect("fixture repo"); + let combined = diff_uncommitted(repo).expect("diff_uncommitted").combined; + let owned = Repository::open(repo.workdir().expect("fixture has a workdir")) + .expect("reopen fixture repo"); + App::new(owned, combined) + } +} + +#[cfg(test)] +mod tests { + use git_workon_fixture::prelude::*; + + use super::test_support::app_from_fixture; + use crate::model::FileStatus; + + #[test] + fn combined_files_arrive_path_sorted() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("z_new.txt", "hello\n") + .unstaged_file("a_tracked.txt", "one\n", "one\nCHANGED\n") + .untracked_file("m_mid.txt", "middle\n") + .build() + .unwrap(); + + let app = app_from_fixture(&fixture); + let paths: Vec<&str> = app.files.iter().map(|f| f.path.as_str()).collect(); + assert_eq!(paths, vec!["a_tracked.txt", "m_mid.txt", "z_new.txt"]); + } + + #[test] + fn ensure_loaded_reads_head_and_worktree_sources_for_modified_file() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("tracked.txt", "line1\nline2\n", "line1\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.ensure_loaded(0); + let view = app.current_view_ref().unwrap(); + assert_eq!(view.old_text(), "line1\nline2\n"); + assert_eq!(view.new_text(), "line1\nCHANGED\n"); + } + + #[test] + fn ensure_loaded_leaves_added_file_old_side_empty() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("new.txt", "hello\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + assert_eq!(app.files[0].status, FileStatus::Added); + app.ensure_loaded(0); + let view = app.current_view_ref().unwrap(); + assert_eq!(view.old_text(), ""); + assert_eq!(view.new_text(), "hello\n"); + } + + #[test] + fn ensure_loaded_leaves_deleted_file_new_side_empty() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .deleted_file("gone.txt", "bye\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + assert_eq!(app.files[0].status, FileStatus::Deleted); + app.ensure_loaded(0); + let view = app.current_view_ref().unwrap(); + assert_eq!(view.old_text(), "bye\n"); + assert_eq!(view.new_text(), ""); + } + + #[test] + fn ensure_loaded_reads_old_path_for_renamed_file() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("old_name.txt", "same content\n", "same content\n") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + let workdir = repo.workdir().unwrap(); + std::fs::rename(workdir.join("old_name.txt"), workdir.join("new_name.txt")).unwrap(); + + let mut app = app_from_fixture(&fixture); + assert_eq!(app.files.len(), 1); + assert_eq!(app.files[0].status, FileStatus::Renamed); + assert_eq!(app.files[0].old_path.as_deref(), Some("old_name.txt")); + app.ensure_loaded(0); + let view = app.current_view_ref().unwrap(); + assert_eq!(view.old_text(), "same content\n"); + assert_eq!(view.new_text(), "same content\n"); + } + + #[test] + fn ensure_loaded_skips_binary_files() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("bin.dat", "hello\n") + .build() + .unwrap(); + // Overwrite the worktree copy with binary content post-build (the fixture staged plain + // text) so the combined diff's content-sniffing sees NUL bytes and flags it binary. + let repo = fixture.repo().unwrap(); + std::fs::write(repo.workdir().unwrap().join("bin.dat"), [0u8, 1, 2, 0, 3]).unwrap(); + + let mut app = app_from_fixture(&fixture); + assert!(app.files[0].is_binary); + app.ensure_loaded(0); + assert!(app.current_view_ref().is_none()); + } +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 9ef8365..2ceba1d 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -14,6 +14,7 @@ pub mod acquire; pub mod align; +pub mod app; pub mod apply; pub mod error; pub mod file_ops; @@ -22,5 +23,6 @@ pub mod model; pub mod ops; pub mod queue; pub mod refresh; +pub mod render; pub mod synthesis; pub mod wordiff; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs new file mode 100644 index 0000000..027272e --- /dev/null +++ b/git-workon-review/src/render.rs @@ -0,0 +1,522 @@ +//! Frame rendering: header, 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 +//! with a full-width `Gap` row (the collapsed-context marker is the same on both sides, so it +//! spans the whole body rather than living in one pane). + +use ratatui::buffer::Buffer; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span as TSpan}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::align::{CellKind, DisplayRow, Row}; +use crate::app::{App, FileView}; +use crate::highlight::FgSpan; +use crate::model::FileStatus; +use crate::wordiff::Span as WordSpan; + +const BG_DEL_SUBTLE: Color = Color::Rgb(60, 24, 24); +const BG_DEL_STRONG: Color = Color::Rgb(120, 40, 40); +const BG_ADD_SUBTLE: Color = Color::Rgb(20, 48, 24); +const BG_ADD_STRONG: Color = Color::Rgb(32, 100, 48); +const FG_DEFAULT: Color = Color::Gray; +const FG_DIM: Color = Color::DarkGray; +const FG_GUTTER: Color = Color::DarkGray; + +/// One resolved (bg, fg) pair for a byte range of a line. +struct Segment { + start: usize, + end: usize, + bg: Option, + fg: Color, +} + +/// Merge background-role spans and syntax fg spans into a flat list of non-overlapping +/// segments covering `[0, len)`. +fn compose_segments( + len: usize, + bg_spans: &[(usize, usize, Color)], + fg_spans: Option<&Vec>, +) -> Vec { + let mut boundaries: Vec = vec![0, len]; + for (s, e, _) in bg_spans { + boundaries.push((*s).min(len)); + boundaries.push((*e).min(len)); + } + if let Some(fgs) = fg_spans { + for span in fgs { + boundaries.push(span.start.min(len)); + boundaries.push(span.end.min(len)); + } + } + boundaries.sort_unstable(); + boundaries.dedup(); + + let mut segments = Vec::with_capacity(boundaries.len()); + for w in boundaries.windows(2) { + let (start, end) = (w[0], w[1]); + if start >= end { + continue; + } + let mid = start; + // Later-pushed bg spans are more specific (word-level strong emphasis is pushed after + // the whole-line subtle span in `build_pane_line`) 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. + 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| s.color) + .unwrap_or(FG_DEFAULT); + segments.push(Segment { start, end, bg, fg }); + } + segments +} + +fn gutter_width(max_lineno: usize) -> usize { + max_lineno.to_string().len().max(3) +} + +/// Which side of the aligned pair a pane line is being built for — determines which of +/// [`FileView`]'s two parallel (text, highlight) sources to read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Side { + Old, + New, +} + +/// Build a single rendered line for one pane at a display row's resolved [`Row`]/[`CellKind`]. +#[allow(clippy::too_many_arguments)] +fn build_pane_line( + view: &FileView, + side: Side, + row: Row, + kind: CellKind, + word_spans: &[WordSpan], + is_word_pair: bool, + subtle_bg: Color, + strong_bg: Color, + gutter_w: usize, + content_w: usize, +) -> Line<'static> { + match row { + Row::Filler => { + let pattern: String = "╱".repeat(content_w + gutter_w + 1); + Line::from(TSpan::styled(pattern, Style::default().fg(FG_DIM))) + } + Row::Line(n) => { + let text = match side { + Side::Old => view.old_line(n), + Side::New => view.new_line(n), + }; + let hl = match side { + Side::Old => view.old_hl.as_ref(), + Side::New => view.new_hl.as_ref(), + } + .and_then(|v| v.get(n - 1)); + + let gutter = format!("{n:>gutter_w$} "); + let mut spans = vec![TSpan::styled(gutter, Style::default().fg(FG_GUTTER))]; + + let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); + match kind { + CellKind::Del | CellKind::Add => { + if is_word_pair { + bg_spans.push((0, text.len(), subtle_bg)); + for s in word_spans { + bg_spans.push((s.start, s.end, strong_bg)); + } + } else { + // Unpaired excess line: whole-line strong emphasis. + bg_spans.push((0, text.len(), strong_bg)); + } + } + CellKind::Context | CellKind::Filler => {} + } + + let segments = compose_segments(text.len(), &bg_spans, hl); + if segments.is_empty() && !text.is_empty() { + spans.push(TSpan::styled( + text.to_string(), + Style::default().fg(FG_DEFAULT), + )); + } + for seg in segments { + let mut style = Style::default().fg(seg.fg); + if let Some(bg) = seg.bg { + style = style.bg(bg); + } + spans.push(TSpan::styled(text[seg.start..seg.end].to_string(), style)); + } + Line::from(spans) + } + } +} + +/// Render one frame: header, SBS body, footer. +pub fn render(frame: &mut Frame, app: &mut App) { + let area = frame.area(); + let vlayout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(area); + + let header_area = vlayout[0]; + let body_area = vlayout[1]; + let footer_area = vlayout[2]; + + render_header(frame, app, header_area); + render_footer(frame, footer_area); + render_body(frame, app, body_area); +} + +fn render_header(frame: &mut Frame, app: &App, area: Rect) { + let idx = app.current + 1; + let n = app.files.len(); + let label = match app.files.get(app.current) { + Some(f) if f.status == FileStatus::Renamed || f.status == FileStatus::Copied => { + format!( + "{} @ {} -> {}", + f.old_path.as_deref().unwrap_or(""), + app.base_label, + f.path + ) + } + Some(f) => f.path.clone(), + None => String::new(), + }; + let text = format!("[{idx}/{n}] {label}"); + frame.render_widget( + Paragraph::new(text).style(Style::default().add_modifier(Modifier::BOLD)), + area, + ); +} + +fn render_footer(frame: &mut Frame, area: Rect) { + let text = "j/k scroll Ctrl-d/u half-page g/G top/bottom ]f/[f file ]h/[h hunk q quit"; + frame.render_widget( + Paragraph::new(text).style(Style::default().fg(FG_DIM)), + area, + ); +} + +/// Write a gap row's `··· N unchanged lines ···` 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. +fn render_gap_row(buf: &mut Buffer, area: Rect, y: u16, skipped: usize) { + let msg = format!("··· {skipped} unchanged lines ···"); + let line = Line::from(TSpan::styled(msg, Style::default().fg(FG_DIM))); + buf.set_line(area.x, y, &line, area.width); +} + +fn render_body(frame: &mut Frame, app: &mut App, area: Rect) { + if app.files.is_empty() { + frame.render_widget(Paragraph::new("(no changes)"), area); + return; + } + + let idx = app.current; + if app.files[idx].is_binary { + let msg = format!("[Binary file: {}]", app.files[idx].path); + frame.render_widget(Paragraph::new(msg).style(Style::default().fg(FG_DIM)), area); + return; + } + + app.ensure_loaded(idx); + app.pane_height = area.height as usize; + + let left_w = area.width.saturating_sub(1) / 2; + let right_w = area.width.saturating_sub(1).saturating_sub(left_w); + let hlayout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(left_w), + Constraint::Length(1), + Constraint::Length(right_w), + ]) + .split(area); + let old_area = hlayout[0]; + let div_area = hlayout[1]; + let new_area = hlayout[2]; + + let Some(view) = app.current_view_ref() else { + frame.render_widget(Paragraph::new("(failed to load file)"), old_area); + return; + }; + let old_gutter_w = gutter_width(view.old_line_count()); + let new_gutter_w = gutter_width(view.new_line_count()); + let scroll = app.scroll; + let pane_height = app.pane_height; + let end = (scroll + pane_height).min(view.display.len()); + + // Phase 1 (mutable): populate the word-span cache for visible paired rows. Phase 2 below + // re-borrows `app`/`view` immutably to build lines — kept as the same two-phase dance the + // spike used (see app.rs's `word_spans_for_row`/`peek_word_spans` split) rather than + // restructured, since `FileView` lives behind `App`'s `Vec>` and the + // borrow checker requires the cache-populating borrow to end before the line-building + // borrow begins; there's no runtime benefit to trading that compile-time proof for + // `RefCell` interior mutability here. + if let Some(view) = app.current_view() { + for row_idx in scroll..end { + if matches!(view.display.get(row_idx), Some(DisplayRow::Row(r)) if r.is_word_diff_pair()) + { + view.word_spans_for_row(row_idx); + } + } + } + + let Some(view) = app.current_view_ref() else { + return; + }; + + for y in area.y..area.y + area.height { + frame + .buffer_mut() + .set_string(div_area.x, y, "│", Style::default().fg(FG_DIM)); + } + + for (i, row_idx) in (scroll..end).enumerate() { + let y = area.y + i as u16; + match &view.display[row_idx] { + DisplayRow::Gap { skipped } => { + render_gap_row(frame.buffer_mut(), area, y, *skipped); + } + DisplayRow::Row(row) => { + let is_pair = row.is_word_diff_pair(); + let (old_words, new_words) = if is_pair { + view.peek_word_spans(row_idx) + } else { + (Vec::new(), Vec::new()) + }; + + let old_line = build_pane_line( + view, + Side::Old, + row.old, + row.old_kind, + &old_words, + is_pair, + BG_DEL_SUBTLE, + BG_DEL_STRONG, + old_gutter_w, + old_area.width as usize, + ); + let new_line = build_pane_line( + view, + Side::New, + row.new, + row.new_kind, + &new_words, + is_pair, + BG_ADD_SUBTLE, + BG_ADD_STRONG, + new_gutter_w, + new_area.width as usize, + ); + frame + .buffer_mut() + .set_line(old_area.x, y, &old_line, old_area.width); + frame + .buffer_mut() + .set_line(new_area.x, y, &new_line, new_area.width); + } + } + } +} + +#[cfg(test)] +mod tests { + use ratatui::backend::TestBackend; + use ratatui::buffer::Buffer; + use ratatui::Terminal; + + use git_workon_fixture::prelude::*; + + use super::render; + use crate::app::test_support::app_from_fixture; + use crate::app::App; + + fn render_once(app: &mut App, width: u16, height: u16) -> Buffer { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| render(f, app)).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() + } + + #[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) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + // `open_current` jumps the viewport straight to the first hunk row (the initial scroll + // behavior CS4 requires), so the leading gap before the hunk scrolls out of view — only + // the trailing gap (after the hunk, before EOF) stays visible at the top of a + // full-height render. + app.open_current(); + let buf = render_once(&mut app, 60, 20); + + let content = buf_lines(&buf); + + assert!( + content.iter().any(|line| line.contains("unchanged lines")), + "expected a collapsed gap row, got:\n{}", + content.join("\n") + ); + assert!( + content.iter().any(|line| line.contains("old word here")), + "expected the old-side changed line, got:\n{}", + content.join("\n") + ); + assert!( + content.iter().any(|line| line.contains("new word here")), + "expected the new-side changed line, got:\n{}", + 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. + let changed_row_y = content + .iter() + .position(|line| line.contains("old word here")) + .expect("changed row present") as u16; + // Gutter width 3 + 1 space = column 4 is where "old" starts. + let word_cell = buf.cell((4, changed_row_y)).unwrap(); + // l10/l11/l12 are the kept-context lines immediately after the hunk (before the + // trailing gap collapses l13/l14). + let ctx_row_y = content + .iter() + .position(|line| line.contains("l10 ")) + .expect("context row present") as u16; + let ctx_cell = buf.cell((4, ctx_row_y)).unwrap(); + assert_ne!( + word_cell.style().bg, + ctx_cell.style().bg, + "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 + // while the unchanged remainder of the same paired line ("word here", from column 8) + // stays subtle — three distinct backgrounds: strong word, subtle 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. + 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" + ); + assert_ne!( + rest_cell.style().bg, + ctx_cell.style().bg, + "expected the paired line's subtle bg to differ from plain context" + ); + } + + #[test] + fn binary_file_shows_placeholder() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("bin.dat", "hello\n") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + std::fs::write(repo.workdir().unwrap().join("bin.dat"), [0u8, 1, 2, 0, 3]).unwrap(); + + let mut app = app_from_fixture(&fixture); + let buf = render_once(&mut app, 60, 10); + + let content = buf_lines(&buf); + assert!( + content + .iter() + .any(|line| line.contains("[Binary file: bin.dat]")), + "expected binary placeholder, got:\n{}", + content.join("\n") + ); + } + + #[test] + fn deleted_file_renders_one_sided() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .deleted_file("gone.txt", "line one\nline two\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + let buf = render_once(&mut app, 60, 10); + + let content = buf_lines(&buf); + assert!( + content.iter().any(|line| line.contains("line one")), + "expected old-side deleted content, got:\n{}", + content.join("\n") + ); + // New (right) pane has nothing to show for a wholly deleted file: every visible row is + // filler on that side. Filler renders as a repeated '╱' run — assert the right half of + // at least one changed row is filler, not "line one"/"line two" text. + let left_w = (buf.area.width.saturating_sub(1)) / 2; + let right_x = left_w + 1; + let row_with_content = content + .iter() + .position(|line| line.contains("line one")) + .expect("row with old content present"); + let right_cell = cell_text(&buf, right_x, row_with_content as u16); + assert_eq!( + right_cell, "╱", + "expected filler on the new-side pane for a deleted file" + ); + } + + #[test] + fn renamed_file_header_shows_old_path_and_base() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("old_name.txt", "same content\n", "same content\n") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + let workdir = repo.workdir().unwrap(); + std::fs::rename(workdir.join("old_name.txt"), workdir.join("new_name.txt")).unwrap(); + + let mut app = app_from_fixture(&fixture); + let buf = render_once(&mut app, 80, 10); + + let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); + assert!( + header.contains("old_name.txt @ HEAD -> new_name.txt"), + "expected renamed header with old_path @ base -> new_path, got: {header:?}" + ); + } +} From 1593bf75f717a56a82c77b4d82d7e2b280d35da6 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 19:16:02 -0400 Subject: [PATCH 024/203] feat(review): wire uncommitted-source TUI with event loop and nav --- Cargo.lock | 1 + Cargo.toml | 1 + git-workon-review/Cargo.toml | 1 + git-workon-review/src/app.rs | 193 +++++++++++++++++++- git-workon-review/src/main.rs | 31 +++- git-workon-review/src/tui.rs | 315 +++++++++++++++++++++++++++++++++ git-workon-review/tests/cli.rs | 20 ++- 7 files changed, 546 insertions(+), 16 deletions(-) create mode 100644 git-workon-review/src/tui.rs diff --git a/Cargo.lock b/Cargo.lock index 454d9ee..85a03b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -961,6 +961,7 @@ version = "0.1.0" dependencies = [ "assert_cmd", "clap", + "crossterm", "git-workon-fixture", "git-workon-lib", "git2", diff --git a/Cargo.toml b/Cargo.toml index 6ece5ef..cf70bc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ clap = { version = "4.6.1", features = [ clap-verbosity-flag = "3.0.4" clap_complete = { version = "4.6.5", features = ["unstable-dynamic"] } clap_mangen = "0.3.0" +crossterm = "0.29.0" dialoguer = { version = "0.12.0", features = ["fuzzy-select"] } env_logger = "0.11.10" git-workon-lib = { version = "0.11.0", path = "./git-workon-lib" } diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index 5de95e6..3ee08fc 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -33,6 +33,7 @@ vendored = ["git-workon-lib/vendored", "git2/vendored-libgit2", "git2/vendored-o [dependencies] clap.workspace = true +crossterm.workspace = true git-workon-lib.workspace = true git2.workspace = true miette.workspace = true diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 510daac..19c8f46 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -300,11 +300,16 @@ impl App { self.row_count().saturating_sub(self.pane_height.max(1)) } + /// Relative scroll, clamped to `[0, max_scroll()]` — except it never snaps backward past + /// the current position when that position is itself beyond `max_scroll()` (e.g. right + /// after [`Self::next_hunk_row`]/[`Self::prev_hunk_row`] jumped the hunk to the top of a + /// pane taller than the remaining display). A relative scroll past a jump-placed position + /// is a no-op in the over-scrolled direction rather than a backward leap; scrolling back the + /// other way still works normally. pub fn scroll_by(&mut self, delta: i64) { - let max = self.max_scroll(); + let max = self.max_scroll() as i64; let cur = self.scroll as i64; - let next = (cur + delta).clamp(0, max as i64); - self.scroll = next as usize; + self.scroll = (cur + delta).clamp(0, max.max(cur)) as usize; } pub fn scroll_top(&mut self) { @@ -314,6 +319,53 @@ impl App { pub fn scroll_bottom(&mut self) { self.scroll = self.max_scroll(); } + + /// Scroll to the next hunk-start row after the current scroll position (`]h`). A no-op if + /// there is no later hunk, or the current file has no loaded view. + pub fn next_hunk_row(&mut self) { + let Some(view) = self.current_view_ref() else { + return; + }; + if let Some(row) = find_next_hunk_row(&view.display, self.scroll) { + self.scroll = row; + } + } + + /// Scroll to the previous hunk-start row before the current scroll position (`[h`). A no-op + /// if there is no earlier hunk, or the current file has no loaded view. + pub fn prev_hunk_row(&mut self) { + let Some(view) = self.current_view_ref() else { + return; + }; + if let Some(row) = find_prev_hunk_row(&view.display, self.scroll) { + self.scroll = row; + } + } +} + +/// True for a display row that carries change content (Del/Add/Filler on either side) rather +/// than pure context — the unit hunk navigation jumps between. +fn is_hunk_content_row(row: &DisplayRow) -> bool { + matches!( + row, + DisplayRow::Row(r) if !(r.old_kind == CellKind::Context && r.new_kind == CellKind::Context) + ) +} + +/// Row index of the next "hunk start" strictly after `after` — a hunk start is a content row +/// whose preceding row is context/gap/absent (i.e. a transition INTO a hunk, not every changed +/// row). Returns `None` if there is no such row. +fn find_next_hunk_row(display: &[DisplayRow], after: usize) -> Option { + (after + 1..display.len()).find(|&i| { + is_hunk_content_row(&display[i]) && (i == 0 || !is_hunk_content_row(&display[i - 1])) + }) +} + +/// Row index of the previous "hunk start" strictly before `before`. See [`find_next_hunk_row`]. +fn find_prev_hunk_row(display: &[DisplayRow], before: usize) -> Option { + (0..before.min(display.len())).rev().find(|&i| { + is_hunk_content_row(&display[i]) && (i == 0 || !is_hunk_content_row(&display[i - 1])) + }) } /// Test-only helper for building an [`App`] straight from a fixture, shared by `app.rs`'s own @@ -342,6 +394,8 @@ mod tests { use git_workon_fixture::prelude::*; use super::test_support::app_from_fixture; + use super::{find_next_hunk_row, find_prev_hunk_row}; + use crate::align::{AlignedRow, CellKind, DisplayRow, Row}; use crate::model::FileStatus; #[test] @@ -444,4 +498,137 @@ mod tests { app.ensure_loaded(0); assert!(app.current_view_ref().is_none()); } + + // Hunk-nav helpers below operate purely over `DisplayRow` vectors — no fixture repo needed. + + fn ctx_row(n: usize) -> DisplayRow { + DisplayRow::Row(AlignedRow { + old: Row::Line(n), + new: Row::Line(n), + old_kind: CellKind::Context, + new_kind: CellKind::Context, + }) + } + + fn change_row(n: usize) -> DisplayRow { + DisplayRow::Row(AlignedRow { + old: Row::Line(n), + new: Row::Line(n), + old_kind: CellKind::Del, + new_kind: CellKind::Add, + }) + } + + fn gap_row(skipped: usize) -> DisplayRow { + DisplayRow::Gap { skipped } + } + + #[test] + fn find_next_hunk_row_skips_within_a_hunk_and_stops_at_the_next_start() { + // ctx, change, change (same hunk — not a new "start"), ctx, ctx, change (next hunk). + let display = vec![ + ctx_row(1), + change_row(2), + change_row(3), + ctx_row(4), + ctx_row(5), + change_row(6), + ]; + assert_eq!(find_next_hunk_row(&display, 0), Some(1)); + // From inside the first hunk, the next START is the second hunk, not row 2 itself. + assert_eq!(find_next_hunk_row(&display, 1), Some(5)); + assert_eq!(find_next_hunk_row(&display, 5), None); + } + + #[test] + fn find_prev_hunk_row_mirrors_next() { + let display = vec![ + ctx_row(1), + change_row(2), + change_row(3), + ctx_row(4), + ctx_row(5), + change_row(6), + ]; + assert_eq!(find_prev_hunk_row(&display, 6), Some(5)); + assert_eq!(find_prev_hunk_row(&display, 5), Some(1)); + assert_eq!(find_prev_hunk_row(&display, 1), None); + } + + #[test] + fn hunk_row_helpers_treat_gap_rows_as_context() { + let display = vec![change_row(1), gap_row(10), change_row(12)]; + assert_eq!(find_next_hunk_row(&display, 0), Some(2)); + assert_eq!(find_prev_hunk_row(&display, 2), Some(0)); + } + + #[test] + fn app_next_and_prev_hunk_row_scroll_between_hunks() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "many.txt", + "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n", + "1\nCHANGED\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\nCHANGED_TOO\n", + ) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + let first_hunk_row = app.scroll; + + app.next_hunk_row(); + assert!( + app.scroll > first_hunk_row, + "should scroll to the later hunk" + ); + let second_hunk_row = app.scroll; + + // No hunk after the last one: no-op. + app.next_hunk_row(); + assert_eq!(app.scroll, second_hunk_row); + + app.prev_hunk_row(); + assert_eq!(app.scroll, first_hunk_row); + + // No hunk before the first one: no-op. + app.prev_hunk_row(); + assert_eq!(app.scroll, first_hunk_row); + } + + #[test] + fn scroll_by_does_not_snap_backward_past_a_hunk_jump() { + // A small file (fits in the default pane height) with two hunks: `max_scroll() == 0`, + // but `next_hunk_row` still jumps `scroll` to the second hunk's row unclamped, leaving + // the view over-scrolled relative to `max_scroll()`. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "small.txt", + "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n", + "1\nCHANGED\n3\n4\n5\n6\n7\n8\n9\nCHANGED_TOO\n", + ) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert_eq!(app.max_scroll(), 0, "whole file must fit in one pane"); + + app.next_hunk_row(); + let over_scrolled = app.scroll; + assert!( + over_scrolled > 0, + "hunk jump should place scroll past max_scroll" + ); + + // `j` (scroll_by(1)) at an over-scrolled position is a no-op, not a backward snap. + app.scroll_by(1); + assert_eq!(app.scroll, over_scrolled); + + // `k` (scroll_by(-1)) still scrolls up normally. + app.scroll_by(-1); + assert_eq!(app.scroll, over_scrolled - 1); + } } diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index d7a3831..5dd2852 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -1,18 +1,33 @@ +mod tui; + use clap::Parser; +use git2::Repository; +use miette::{IntoDiagnostic, Result}; +use workon_review::acquire::diff_uncommitted; +use workon_review::app::App; /// A TUI for reviewing changesets #[derive(Debug, Parser)] -#[clap( - about, - author, - bin_name = env!("CARGO_PKG_NAME"), - version, - arg_required_else_help = true -)] +#[clap(about, author, bin_name = env!("CARGO_PKG_NAME"), version)] struct Cli {} -fn main() -> miette::Result<()> { +fn main() -> Result<()> { Cli::parse(); + let repo = Repository::discover(".").into_diagnostic()?; + let combined = diff_uncommitted(&repo).into_diagnostic()?.combined; + + if combined.files.is_empty() { + eprintln!("nothing to review"); + return Ok(()); + } + + // `App` owns its own `Repository` handle (see `app.rs`'s doc comment) — moved in here after + // `diff_uncommitted` is done borrowing it. + let mut app = App::new(repo, combined); + app.open_current(); + + tui::run(&mut app).into_diagnostic()?; + Ok(()) } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs new file mode 100644 index 0000000..3619b1d --- /dev/null +++ b/git-workon-review/src/tui.rs @@ -0,0 +1,315 @@ +//! Terminal lifecycle, event seam, and the main input loop for the review TUI. +//! +//! Ported loop shape from the `review-tui-spike` prototype's `main.rs` (`install_panic_hook`, +//! raw-mode + alternate-screen setup, `draw -> quit-check -> next_event -> update`), adapted to +//! read events through [`next_event`] rather than calling crossterm directly from the loop: M4 +//! swaps `next_event`'s internals for an mpsc channel fed by watcher threads without changing +//! the loop shape or [`AppEvent`]'s shape. + +use std::io::{self, Stdout}; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; +use workon_review::app::App; +use workon_review::render; + +/// One event the review loop reacts to. `next_event`'s crossterm-specific mapping is the only +/// piece M4 will replace (for an mpsc channel fed by a file-watcher thread) — the loop and this +/// enum stay the same shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppEvent { + Key(KeyEvent), + Resize(u16, u16), + Tick, +} + +/// Poll for the next terminal event, up to `timeout`. +/// +/// `Ok(Some(AppEvent::Tick))` on a plain timeout (the loop's regular redraw beat); `Ok(None)` for +/// a terminal event we don't map to an [`AppEvent`] (key release/repeat, mouse, paste, focus) — +/// the loop redraws and keeps going without calling `update`. +pub fn next_event(timeout: Duration) -> io::Result> { + if !event::poll(timeout)? { + return Ok(Some(AppEvent::Tick)); + } + Ok(match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => Some(AppEvent::Key(key)), + Event::Resize(w, h) => Some(AppEvent::Resize(w, h)), + _ => None, + }) +} + +/// The action a mapped key requests, independent of any [`App`] — kept separate from +/// [`map_key`]'s dispatch so the mapping itself is unit-testable without building an `App`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Action { + Quit, + ScrollBy(i64), + ScrollTop, + ScrollBottom, + NextFile, + PrevFile, + NextHunk, + PrevHunk, + None, +} + +/// Map one key press to an [`Action`], given `pending` (a `]` or `[` seen on the previous call, +/// awaiting its `f`/`h` suffix) and the current pane height (for `Ctrl-d`/`Ctrl-u` half-page +/// deltas). Unrecognized suffixes drop the pending bracket rather than re-processing the key. +fn map_key(pending: &mut Option, key: KeyEvent, pane_height: usize) -> Action { + if let Some(bracket) = pending.take() { + return match (bracket, key.code) { + (']', KeyCode::Char('f')) => Action::NextFile, + ('[', KeyCode::Char('f')) => Action::PrevFile, + (']', KeyCode::Char('h')) => Action::NextHunk, + ('[', KeyCode::Char('h')) => Action::PrevHunk, + _ => Action::None, + }; + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => Action::Quit, + KeyCode::Char('j') | KeyCode::Down => Action::ScrollBy(1), + KeyCode::Char('k') | KeyCode::Up => Action::ScrollBy(-1), + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Action::ScrollBy((pane_height / 2).max(1) as i64) + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Action::ScrollBy(-((pane_height / 2).max(1) as i64)) + } + KeyCode::Char('g') => Action::ScrollTop, + KeyCode::Char('G') => Action::ScrollBottom, + KeyCode::Tab => Action::NextFile, + KeyCode::BackTab => Action::PrevFile, + KeyCode::Char(']') => { + *pending = Some(']'); + Action::None + } + KeyCode::Char('[') => { + *pending = Some('['); + Action::None + } + _ => Action::None, + } +} + +/// Apply an [`Action`] to `app`. Returns `true` when the loop should exit. +fn apply_action(app: &mut App, action: Action) -> bool { + match action { + Action::Quit => return true, + Action::ScrollBy(delta) => app.scroll_by(delta), + Action::ScrollTop => app.scroll_top(), + Action::ScrollBottom => app.scroll_bottom(), + Action::NextFile => app.next_file(), + Action::PrevFile => app.prev_file(), + Action::NextHunk => app.next_hunk_row(), + Action::PrevHunk => app.prev_hunk_row(), + Action::None => {} + } + false +} + +/// Apply one [`AppEvent`] to `app`. Returns `true` when the loop should exit (q/Esc). Resize and +/// Tick are no-ops today — ratatui re-measures `body_area` every frame regardless, and Tick +/// exists for M4's periodic-refresh consumers, not M3's read-only loop. +fn update(app: &mut App, pending: &mut Option, event: AppEvent) -> bool { + match event { + AppEvent::Key(key) => apply_action(app, map_key(pending, key, app.pane_height)), + AppEvent::Resize(_, _) | AppEvent::Tick => false, + } +} + +/// Install a panic hook that restores the terminal (raw mode off, leave alternate screen) before +/// the default hook prints the panic — without this, a panic mid-review leaves the user's shell +/// in alternate-screen raw mode with no visible message. Ported from the spike's +/// `install_panic_hook`. +fn install_panic_hook() { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + default_hook(info); + })); +} + +/// Run the review TUI's terminal lifecycle and main loop against `app`. Callers must have +/// already loaded the initial file (`app.open_current()`) before calling this. +pub fn run(app: &mut App) -> io::Result<()> { + install_panic_hook(); + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = event_loop(&mut terminal, app); + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + result +} + +fn event_loop(terminal: &mut Terminal>, app: &mut App) -> io::Result<()> { + let mut pending: Option = None; + let mut quit = false; + + loop { + terminal.draw(|f| render::render(f, app))?; + + if quit { + return Ok(()); + } + + if let Some(event) = next_event(Duration::from_millis(200))? { + quit = update(app, &mut pending, event); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn ctrl_key(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) + } + + #[test] + fn quit_keys_map_to_quit() { + let mut pending = None; + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('q')), 20), + Action::Quit + ); + assert_eq!(map_key(&mut pending, key(KeyCode::Esc), 20), Action::Quit); + } + + #[test] + fn scroll_keys_map_by_one_line() { + let mut pending = None; + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('j')), 20), + Action::ScrollBy(1) + ); + assert_eq!( + map_key(&mut pending, key(KeyCode::Down), 20), + Action::ScrollBy(1) + ); + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('k')), 20), + Action::ScrollBy(-1) + ); + assert_eq!( + map_key(&mut pending, key(KeyCode::Up), 20), + Action::ScrollBy(-1) + ); + } + + #[test] + fn ctrl_d_u_scroll_by_half_the_pane_height() { + let mut pending = None; + assert_eq!( + map_key(&mut pending, ctrl_key('d'), 21), + Action::ScrollBy(10) + ); + assert_eq!( + map_key(&mut pending, ctrl_key('u'), 21), + Action::ScrollBy(-10) + ); + // A pane height of 1 still scrolls by at least one line. + assert_eq!(map_key(&mut pending, ctrl_key('d'), 1), Action::ScrollBy(1)); + } + + #[test] + fn g_and_shift_g_map_to_top_and_bottom() { + let mut pending = None; + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('g')), 20), + Action::ScrollTop + ); + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('G')), 20), + Action::ScrollBottom + ); + } + + #[test] + fn tab_and_backtab_map_to_file_nav() { + let mut pending = None; + assert_eq!( + map_key(&mut pending, key(KeyCode::Tab), 20), + Action::NextFile + ); + assert_eq!( + map_key(&mut pending, key(KeyCode::BackTab), 20), + Action::PrevFile + ); + } + + #[test] + fn bracket_f_maps_to_file_nav() { + let mut pending = None; + assert_eq!( + map_key(&mut pending, key(KeyCode::Char(']')), 20), + Action::None + ); + assert_eq!(pending, Some(']')); + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('f')), 20), + Action::NextFile + ); + assert_eq!(pending, None); + + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('[')), 20), + Action::None + ); + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('f')), 20), + Action::PrevFile + ); + } + + #[test] + fn bracket_h_maps_to_hunk_nav() { + let mut pending = None; + map_key(&mut pending, key(KeyCode::Char(']')), 20); + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('h')), 20), + Action::NextHunk + ); + + map_key(&mut pending, key(KeyCode::Char('[')), 20); + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('h')), 20), + Action::PrevHunk + ); + } + + #[test] + fn unrecognized_bracket_suffix_drops_pending_without_side_effect() { + let mut pending = None; + map_key(&mut pending, key(KeyCode::Char(']')), 20); + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('x')), 20), + Action::None + ); + assert_eq!( + pending, None, + "pending bracket must be cleared, not left dangling" + ); + } +} diff --git a/git-workon-review/tests/cli.rs b/git-workon-review/tests/cli.rs index c621cf2..28b9c88 100644 --- a/git-workon-review/tests/cli.rs +++ b/git-workon-review/tests/cli.rs @@ -1,12 +1,22 @@ use assert_cmd::cargo_bin_cmd; -use predicates::prelude::*; +use git_workon_fixture::prelude::*; +/// Locked design decision #7 (M3 plan): a clean worktree prints "nothing to review" to stderr +/// and exits 0 without ever entering the TUI — no raw-mode/alternate-screen setup, so this stays +/// a plain `assert_cmd` invocation (no PTY needed). #[test] -fn no_args_shows_usage_and_fails() { +fn clean_worktree_prints_nothing_to_review_and_exits_success() { + let fixture = FixtureBuilder::new().build().unwrap(); + let repo = fixture.repo().unwrap(); + let workdir = repo.workdir().unwrap(); + let mut cmd = cargo_bin_cmd!("git-workon-review"); - cmd.assert() - .failure() - .stderr(predicate::str::contains("Usage")); + cmd.current_dir(workdir) + .env("NO_COLOR", "1") + .assert() + .success() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("nothing to review")); } #[test] From 64b9bb9765217fc4ad45daf1cc5d10e3ca9a78de Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 19:41:53 -0400 Subject: [PATCH 025/203] feat(review): add inline layout with runtime toggle --- git-workon-review/src/align.rs | 214 +++++++++++++++++++++++ git-workon-review/src/app.rs | 300 +++++++++++++++++++++++++++++++- git-workon-review/src/render.rs | 287 ++++++++++++++++++++++++++---- git-workon-review/src/tui.rs | 12 ++ 4 files changed, 772 insertions(+), 41 deletions(-) diff --git a/git-workon-review/src/align.rs b/git-workon-review/src/align.rs index f642181..931bd10 100644 --- a/git-workon-review/src/align.rs +++ b/git-workon-review/src/align.rs @@ -259,6 +259,116 @@ fn collapse_gaps_with(rows: &[AlignedRow], context: usize) -> Vec { out } +/// One row of the inline (unified, single-column) display. +/// +/// Built by [`inline_rows`] from the SAME gap-collapsed [`DisplayRow`] vector [`collapse_gaps`] +/// already produces for the side-by-side layout — inline reuses that pass unchanged rather than +/// re-running gap collapse over its own row type (context-gap detection is layout-agnostic; only +/// how the surviving rows spread onto the screen differs). Because a del/add change block +/// becomes MULTIPLE `InlineRow` entries (deletions first, then additions — there's no second +/// column to pad against, so unlike [`AlignedRow`] there is no `Filler` variant here), this +/// vector's indices are a DIFFERENT coordinate space than `display`'s: [`crate::app::FileView`] +/// keeps a separate word-span cache keyed by THIS vector's row index. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InlineRow { + /// An unchanged line; carries both linenos since old and new agree on its content. + Context { + old: usize, + new: usize, + }, + /// A deleted line. `paired_new` is the addition it was aligned with in the SAME + /// [`AlignedRow`] (index-paired within the change block), if any — kept only so the renderer + /// can still run word-level diffing on the pair even though the two lines are no longer + /// visually adjacent. + Del { + old: usize, + paired_new: Option, + }, + /// An added line. `paired_old` mirrors [`InlineRow::Del::paired_new`]. + Add { + new: usize, + paired_old: Option, + }, + Gap { + skipped: usize, + }, +} + +impl InlineRow { + /// True when this row has an index-paired counterpart on the other side, eligible for + /// word-level diffing — the inline analog of [`AlignedRow::is_word_diff_pair`]. + pub fn is_word_diff_pair(&self) -> bool { + matches!( + self, + InlineRow::Del { + paired_new: Some(_), + .. + } | InlineRow::Add { + paired_old: Some(_), + .. + } + ) + } +} + +/// Convert a gap-collapsed side-by-side display vector into the inline layout's row vector. +/// +/// Walks maximal runs of non-context rows (a "change block": consecutive `AlignedRow`s where +/// `old_kind`/`new_kind` isn't `(Context, Context)`) and, within each run, emits every deletion +/// line first, then every addition line — matching git's own convention of listing removed lines +/// before added ones — dropping `Filler` entries entirely (inline has no second column to pad +/// against). +pub fn inline_rows(display: &[DisplayRow]) -> Vec { + let mut out = Vec::with_capacity(display.len()); + let mut run: Vec = Vec::new(); + + fn flush(run: &mut Vec, out: &mut Vec) { + for r in run.iter().filter(|r| r.old_kind == CellKind::Del) { + let Row::Line(old) = r.old else { + unreachable!("a Del row always carries a Line on its old side") + }; + let paired_new = match r.new { + Row::Line(n) if r.new_kind == CellKind::Add => Some(n), + _ => None, + }; + out.push(InlineRow::Del { old, paired_new }); + } + for r in run.iter().filter(|r| r.new_kind == CellKind::Add) { + let Row::Line(new) = r.new else { + unreachable!("an Add row always carries a Line on its new side") + }; + let paired_old = match r.old { + Row::Line(o) if r.old_kind == CellKind::Del => Some(o), + _ => None, + }; + out.push(InlineRow::Add { new, paired_old }); + } + run.clear(); + } + + for row in display { + match row { + DisplayRow::Gap { skipped } => { + flush(&mut run, &mut out); + out.push(InlineRow::Gap { skipped: *skipped }); + } + DisplayRow::Row(r) + if r.old_kind == CellKind::Context && r.new_kind == CellKind::Context => + { + flush(&mut run, &mut out); + let (Row::Line(old), Row::Line(new)) = (r.old, r.new) else { + unreachable!("a Context row always carries a Line on both sides") + }; + out.push(InlineRow::Context { old, new }); + } + DisplayRow::Row(r) => run.push(*r), + } + } + flush(&mut run, &mut out); + + out +} + #[cfg(test)] mod tests { use super::*; @@ -529,4 +639,108 @@ mod tests { other => panic!("expected gap row, got {other:?}"), } } + + #[test] + fn inline_del_run_precedes_add_run_within_a_block() { + // 3 dels / 1 add block: SBS index-pairs del[0]/add[0] and fillers the rest; inline must + // emit all 3 dels first, then the 1 add — not interleaved by pairing index. + let h = hunk( + 1, + 5, + 1, + 3, + vec![ + hl(LineKind::Context, Some(1), Some(1)), + hl(LineKind::Deletion, Some(2), None), + hl(LineKind::Deletion, Some(3), None), + hl(LineKind::Deletion, Some(4), None), + hl(LineKind::Addition, None, Some(2)), + hl(LineKind::Context, Some(5), Some(3)), + ], + ); + let aligned = align_file(&[h], 5, 3); + let display = collapse_gaps(&aligned.rows); + let inline = inline_rows(&display); + + assert_eq!( + inline, + vec![ + InlineRow::Context { old: 1, new: 1 }, + InlineRow::Del { + old: 2, + paired_new: Some(2) + }, + InlineRow::Del { + old: 3, + paired_new: None + }, + InlineRow::Del { + old: 4, + paired_new: None + }, + InlineRow::Add { + new: 2, + paired_old: Some(2) + }, + InlineRow::Context { old: 5, new: 3 }, + ] + ); + } + + #[test] + fn inline_has_no_filler_rows() { + let h = hunk( + 0, + 0, + 1, + 2, + vec![ + hl(LineKind::Addition, None, Some(1)), + hl(LineKind::Addition, None, Some(2)), + ], + ); + let aligned = align_file(&[h], 0, 2); + let display = collapse_gaps(&aligned.rows); + let inline = inline_rows(&display); + + assert_eq!( + inline, + vec![ + InlineRow::Add { + new: 1, + paired_old: None + }, + InlineRow::Add { + new: 2, + paired_old: None + }, + ] + ); + } + + #[test] + fn inline_passes_gap_rows_through_unchanged() { + let mut rows = vec![change_row( + Row::Line(1), + Row::Line(1), + CellKind::Del, + CellKind::Add, + )]; + rows.extend((2..=11).map(context_row)); + rows.push(change_row( + Row::Line(12), + Row::Line(12), + CellKind::Del, + CellKind::Add, + )); + + let display = collapse_gaps_with(&rows, 3); + let inline = inline_rows(&display); + assert!( + inline + .iter() + .any(|r| matches!(r, InlineRow::Gap { skipped: 4 })), + "expected the gap row to survive the inline conversion unchanged: {inline:?}" + ); + } } diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 19c8f46..7d84d53 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -14,7 +14,7 @@ use std::path::Path; use git2::Repository; -use crate::align::{align_file, collapse_gaps, CellKind, DisplayRow, Row}; +use crate::align::{align_file, collapse_gaps, inline_rows, CellKind, DisplayRow, InlineRow, Row}; use crate::highlight::{FgSpan, TsHighlighter}; use crate::model::{DiffModel, FileChange, FileStatus}; use crate::wordiff::{word_diff_spans, Span}; @@ -51,6 +51,15 @@ pub struct FileView { /// Lazily computed word-diff spans, keyed by DISPLAY row index — the only coordinate the /// renderer's viewport walks once gaps are collapsed. word_spans: HashMap, Vec)>, + /// The inline layout's row list, derived from [`Self::display`] via + /// [`crate::align::inline_rows`] — see that function's doc comment for why this is a + /// separate vector rather than a re-collapse over its own row type. + pub inline: Vec, + /// Word-diff span cache for the inline layout, keyed by [`Self::inline`]'s row index — a + /// SEPARATE coordinate space from [`Self::word_spans`] (a paired del/add block becomes two + /// `InlineRow` entries at different indices instead of one `AlignedRow`), so the two caches + /// cannot share keys. + inline_word_spans: HashMap, Vec)>, } impl FileView { @@ -88,6 +97,7 @@ impl FileView { let old_hl = ts.highlight_file(old_source_path, &old_text); let new_hl = ts.highlight_file(&file.path, &new_text); + let inline = inline_rows(&display); Self { old_text, @@ -99,6 +109,8 @@ impl FileView { old_hl, new_hl, word_spans: HashMap::new(), + inline, + inline_word_spans: HashMap::new(), } } @@ -166,6 +178,44 @@ impl FileView { .cloned() .unwrap_or_default() } + + /// Inline-layout analog of [`Self::word_spans_for_row`], keyed by [`Self::inline`]'s row + /// index instead of [`Self::display`]'s. A `Del`/`Add` row with no paired counterpart (an + /// unpaired excess line) returns empty spans without populating the cache, same as the SBS + /// version. + pub fn inline_word_spans_for_row(&mut self, inline_idx: usize) -> (Vec, Vec) { + if let Some(cached) = self.inline_word_spans.get(&inline_idx) { + return cached.clone(); + } + let pair = match self.inline.get(inline_idx) { + Some(InlineRow::Del { + old, + paired_new: Some(new), + }) => Some((*old, *new)), + Some(InlineRow::Add { + new, + paired_old: Some(old), + }) => Some((*old, *new)), + _ => None, + }; + match pair { + Some((old, new)) => { + let spans = word_diff_spans(self.old_line(old), self.new_line(new)); + self.inline_word_spans.insert(inline_idx, spans.clone()); + spans + } + None => (Vec::new(), Vec::new()), + } + } + + /// Read-only peek at an already-cached inline word-diff span pair (empty if uncached). See + /// [`Self::peek_word_spans`]. + pub fn peek_inline_word_spans(&self, inline_idx: usize) -> (Vec, Vec) { + self.inline_word_spans + .get(&inline_idx) + .cloned() + .unwrap_or_default() + } } fn read_head_blob(repo: &Repository, tree: &git2::Tree<'_>, path: &str) -> String { @@ -185,6 +235,16 @@ fn read_workdir_file(repo: &Repository, path: &str) -> String { .unwrap_or_default() } +/// Which layout the renderer draws the current file's rows in — runtime-toggled via `L` +/// (prototype analog: `rl`), and persists across file navigation (neither +/// [`App::next_file`]/[`App::prev_file`] nor [`App::open_current`] touch it). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Layout { + #[default] + Sbs, + Inline, +} + /// Review session state: the combined diff's file list, per-file lazily loaded views, and /// navigation/scroll state. One long-lived [`TsHighlighter`] lives here (not per file) — its /// language-config cache is keyed per-instance, so a fresh highlighter per file would rebuild @@ -204,6 +264,8 @@ pub struct App { /// M4's committed-changeset zoom will want to set this to the changeset's actual base rev. pub base_label: String, highlighter: TsHighlighter, + /// Current render layout; see [`Layout`]'s doc comment for the persistence contract. + pub layout: Layout, } impl App { @@ -218,6 +280,7 @@ impl App { pane_height: 20, base_label: "HEAD".to_string(), highlighter: TsHighlighter::new(), + layout: Layout::default(), } } @@ -292,7 +355,10 @@ impl App { fn row_count(&self) -> usize { self.current_view_ref() - .map(|v| v.display.len()) + .map(|v| match self.layout { + Layout::Sbs => v.display.len(), + Layout::Inline => v.inline.len(), + }) .unwrap_or(0) } @@ -321,26 +387,54 @@ impl App { } /// Scroll to the next hunk-start row after the current scroll position (`]h`). A no-op if - /// there is no later hunk, or the current file has no loaded view. + /// there is no later hunk, or the current file has no loaded view. Searches [`Self::layout`]'s + /// own row vector and coordinate space — `scroll` is an index into `display` under + /// [`Layout::Sbs`] but into `inline` under [`Layout::Inline`], and the two disagree on row + /// count/position whenever a change block has unequal del/add counts. pub fn next_hunk_row(&mut self) { let Some(view) = self.current_view_ref() else { return; }; - if let Some(row) = find_next_hunk_row(&view.display, self.scroll) { + let next = match self.layout { + Layout::Sbs => find_next_hunk_row(&view.display, self.scroll), + Layout::Inline => find_next_inline_hunk_row(&view.inline, self.scroll), + }; + if let Some(row) = next { self.scroll = row; } } /// Scroll to the previous hunk-start row before the current scroll position (`[h`). A no-op - /// if there is no earlier hunk, or the current file has no loaded view. + /// if there is no earlier hunk, or the current file has no loaded view. See + /// [`Self::next_hunk_row`] for why the search dispatches on [`Self::layout`]. pub fn prev_hunk_row(&mut self) { let Some(view) = self.current_view_ref() else { return; }; - if let Some(row) = find_prev_hunk_row(&view.display, self.scroll) { + let prev = match self.layout { + Layout::Sbs => find_prev_hunk_row(&view.display, self.scroll), + Layout::Inline => find_prev_inline_hunk_row(&view.inline, self.scroll), + }; + if let Some(row) = prev { self.scroll = row; } } + + /// Toggle between side-by-side and inline layouts (`L`). Deliberately does not try to + /// re-derive an equivalent `scroll` position for the new layout — the two layouts' row + /// vectors track the same underlying content in a different shape, and translating exactly + /// isn't worth the complexity for M3; the user re-orients same as they would after a resize. + /// It DOES clamp `scroll` to the new layout's `max_scroll()`, though: inline is strictly + /// taller than SBS whenever paired del/add blocks exist, so a scroll position picked up + /// there (including an over-scrolled hunk-jump position, see [`Self::scroll_by`]) can exceed + /// SBS's shorter range. + pub fn toggle_layout(&mut self) { + self.layout = match self.layout { + Layout::Sbs => Layout::Inline, + Layout::Inline => Layout::Sbs, + }; + self.scroll = self.scroll.min(self.max_scroll()); + } } /// True for a display row that carries change content (Del/Add/Filler on either side) rather @@ -368,6 +462,30 @@ fn find_prev_hunk_row(display: &[DisplayRow], before: usize) -> Option { }) } +/// Inline-layout analog of [`is_hunk_content_row`]: true for a `Del`/`Add` row (inline has no +/// `Filler` variant — see [`InlineRow`]'s doc comment), false for `Context`/`Gap`. +fn is_inline_hunk_content_row(row: &InlineRow) -> bool { + matches!(row, InlineRow::Del { .. } | InlineRow::Add { .. }) +} + +/// Inline-layout analog of [`find_next_hunk_row`]: row index of the next "hunk start" (a +/// `Del`/`Add` row whose predecessor is `Context`/`Gap`/absent) strictly after `after`, searching +/// [`crate::app::FileView::inline`] instead of `display`. +fn find_next_inline_hunk_row(inline: &[InlineRow], after: usize) -> Option { + (after + 1..inline.len()).find(|&i| { + is_inline_hunk_content_row(&inline[i]) + && (i == 0 || !is_inline_hunk_content_row(&inline[i - 1])) + }) +} + +/// Inline-layout analog of [`find_prev_hunk_row`]. See [`find_next_inline_hunk_row`]. +fn find_prev_inline_hunk_row(inline: &[InlineRow], before: usize) -> Option { + (0..before.min(inline.len())).rev().find(|&i| { + is_inline_hunk_content_row(&inline[i]) + && (i == 0 || !is_inline_hunk_content_row(&inline[i - 1])) + }) +} + /// Test-only helper for building an [`App`] straight from a fixture, shared by `app.rs`'s own /// tests and `render.rs`'s frame tests. `App` owns its `Repository` handle, but /// [`git_workon_fixture::fixture::Fixture::repo`] only lends a borrowed one — so this opens a @@ -395,7 +513,7 @@ mod tests { use super::test_support::app_from_fixture; use super::{find_next_hunk_row, find_prev_hunk_row}; - use crate::align::{AlignedRow, CellKind, DisplayRow, Row}; + use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::model::FileStatus; #[test] @@ -631,4 +749,172 @@ mod tests { app.scroll_by(-1); assert_eq!(app.scroll, over_scrolled - 1); } + + #[test] + fn toggle_layout_flips_and_persists_across_file_nav() { + use super::Layout; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .untracked_file("b.txt", "hello\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + assert_eq!(app.layout, Layout::Sbs, "default layout is side-by-side"); + + app.toggle_layout(); + assert_eq!(app.layout, Layout::Inline); + + // Navigating files must not reset the layout choice. + app.next_file(); + assert_eq!( + app.layout, + Layout::Inline, + "layout must persist across next_file" + ); + app.prev_file(); + assert_eq!( + app.layout, + Layout::Inline, + "layout must persist across prev_file" + ); + + app.toggle_layout(); + assert_eq!(app.layout, Layout::Sbs, "toggling back returns to Sbs"); + } + + #[test] + fn inline_word_spans_cache_and_peek_round_trip() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("small.txt", "old word here\n", "new word here\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.ensure_loaded(0); + let view = app.current_view().unwrap(); + + // The single change block here is a 1-del/1-add pair: inline row 0 is the Del, row 1 is + // the paired Add. + assert!(view.inline[0].is_word_diff_pair()); + assert!(view.inline[1].is_word_diff_pair()); + + // Uncached before the populating call. + assert_eq!(view.peek_inline_word_spans(0), (Vec::new(), Vec::new())); + + let (old_spans, new_spans) = view.inline_word_spans_for_row(0); + assert!(!old_spans.is_empty(), "expected the changed word's span"); + + // Now cached: peek returns the same spans without recomputing. + assert_eq!(view.peek_inline_word_spans(0), (old_spans, new_spans)); + } + + #[test] + fn inline_layout_scroll_bottom_reaches_full_tail() { + use super::Layout; + + // Every line paired-changed: each display row (one Del/Add pair) expands to TWO inline + // rows (Del then Add), so `inline.len() > display.len()` — under the F1 bug, scroll + // bounds were still clamped against the shorter `display` length, leaving the inline + // tail unreachable. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("small.txt", "1\n2\n3\n4\n5\n", "a\nb\nc\nd\ne\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.layout = Layout::Inline; + app.pane_height = 2; + + let inline_len = app.current_view_ref().unwrap().inline.len(); + let display_len = app.current_view_ref().unwrap().display.len(); + assert!( + inline_len > display_len, + "paired changed lines must expand under inline layout" + ); + + app.scroll_bottom(); + assert_eq!( + app.scroll, + inline_len - app.pane_height, + "scroll_bottom must reach the inline tail, not the shorter SBS tail" + ); + } + + #[test] + fn inline_layout_next_and_prev_hunk_row_jump_between_change_blocks() { + use super::Layout; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "many.txt", + "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n", + "1\nCHANGED\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\nCHANGED_TOO\n", + ) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.ensure_loaded(0); + app.layout = Layout::Inline; + app.scroll = 0; + + app.next_hunk_row(); + let first_block_row = app.scroll; + assert!( + matches!( + app.current_view_ref().unwrap().inline[first_block_row], + InlineRow::Del { .. } | InlineRow::Add { .. } + ), + "next_hunk_row must land on a Del/Add inline row" + ); + + app.next_hunk_row(); + let second_block_row = app.scroll; + assert!( + second_block_row > first_block_row, + "should jump to the later change block" + ); + assert!(matches!( + app.current_view_ref().unwrap().inline[second_block_row], + InlineRow::Del { .. } | InlineRow::Add { .. } + )); + + app.prev_hunk_row(); + assert_eq!( + app.scroll, first_block_row, + "prev_hunk_row should return to the earlier block" + ); + } + + #[test] + fn toggle_layout_clamps_out_of_range_scroll() { + use super::Layout; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("small.txt", "1\n2\n3\n4\n5\n", "a\nb\nc\nd\ne\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.layout = Layout::Inline; + app.pane_height = 2; + app.scroll_bottom(); + assert!(app.scroll > 0, "inline scroll should be over the SBS max"); + + app.toggle_layout(); + assert_eq!(app.layout, Layout::Sbs, "toggling back returns to Sbs"); + assert!( + app.scroll <= app.max_scroll(), + "scroll must be clamped to the new layout's max_scroll" + ); + } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 027272e..266c46f 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -12,8 +12,8 @@ use ratatui::text::{Line, Span as TSpan}; use ratatui::widgets::Paragraph; use ratatui::Frame; -use crate::align::{CellKind, DisplayRow, Row}; -use crate::app::{App, FileView}; +use crate::align::{CellKind, DisplayRow, InlineRow, Row}; +use crate::app::{App, FileView, Layout as AppLayout}; use crate::highlight::FgSpan; use crate::model::FileStatus; use crate::wordiff::Span as WordSpan; @@ -63,7 +63,7 @@ fn compose_segments( } let mid = start; // Later-pushed bg spans are more specific (word-level strong emphasis is pushed after - // the whole-line subtle span in `build_pane_line`) and must win, so the lookup scans in + // the whole-line subtle span 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. let bg = bg_spans @@ -92,6 +92,52 @@ enum Side { New, } +/// Build the styled content spans (everything after the gutter) for one line of text, shared by +/// [`build_pane_line`] (SBS) and [`build_inline_line`] (inline) — the two differ only in how they +/// resolve `text`/`hl`/`emphasis` from a [`Row`] vs an [`InlineRow`] and in their gutter, not in +/// how a resolved line gets colored. +/// +/// `emphasis` is `Some((subtle, strong))` for a `Del`/`Add` line (whole-line subtle background, +/// plus per-`word_spans` strong background when `is_word_pair`; whole-line strong when not paired +/// — an unpaired excess line) and `None` for `Context`/`Filler` (no background emphasis at all). +fn content_spans( + text: &str, + hl: Option<&Vec>, + emphasis: Option<(Color, Color)>, + word_spans: &[WordSpan], + is_word_pair: bool, +) -> Vec> { + let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); + if let Some((subtle_bg, strong_bg)) = emphasis { + if is_word_pair { + bg_spans.push((0, text.len(), subtle_bg)); + for s in word_spans { + bg_spans.push((s.start, s.end, strong_bg)); + } + } else { + // Unpaired excess line: whole-line strong emphasis. + bg_spans.push((0, text.len(), strong_bg)); + } + } + + let segments = compose_segments(text.len(), &bg_spans, hl); + let mut spans = Vec::with_capacity(segments.len().max(1)); + if segments.is_empty() && !text.is_empty() { + spans.push(TSpan::styled( + text.to_string(), + Style::default().fg(FG_DEFAULT), + )); + } + for seg in segments { + let mut style = Style::default().fg(seg.fg); + if let Some(bg) = seg.bg { + style = style.bg(bg); + } + spans.push(TSpan::styled(text[seg.start..seg.end].to_string(), style)); + } + spans +} + /// Build a single rendered line for one pane at a display row's resolved [`Row`]/[`CellKind`]. #[allow(clippy::too_many_arguments)] fn build_pane_line( @@ -125,36 +171,11 @@ fn build_pane_line( let gutter = format!("{n:>gutter_w$} "); let mut spans = vec![TSpan::styled(gutter, Style::default().fg(FG_GUTTER))]; - let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); - match kind { - CellKind::Del | CellKind::Add => { - if is_word_pair { - bg_spans.push((0, text.len(), subtle_bg)); - for s in word_spans { - bg_spans.push((s.start, s.end, strong_bg)); - } - } else { - // Unpaired excess line: whole-line strong emphasis. - bg_spans.push((0, text.len(), strong_bg)); - } - } - CellKind::Context | CellKind::Filler => {} - } - - let segments = compose_segments(text.len(), &bg_spans, hl); - if segments.is_empty() && !text.is_empty() { - spans.push(TSpan::styled( - text.to_string(), - Style::default().fg(FG_DEFAULT), - )); - } - for seg in segments { - let mut style = Style::default().fg(seg.fg); - if let Some(bg) = seg.bg { - style = style.bg(bg); - } - spans.push(TSpan::styled(text[seg.start..seg.end].to_string(), style)); - } + let emphasis = match kind { + CellKind::Del | CellKind::Add => Some((subtle_bg, strong_bg)), + CellKind::Context | CellKind::Filler => None, + }; + spans.extend(content_spans(text, hl, emphasis, word_spans, is_word_pair)); Line::from(spans) } } @@ -204,7 +225,8 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { } fn render_footer(frame: &mut Frame, area: Rect) { - let text = "j/k scroll Ctrl-d/u half-page g/G top/bottom ]f/[f file ]h/[h hunk q quit"; + let text = + "j/k scroll Ctrl-d/u half-page g/G top/bottom ]f/[f file ]h/[h hunk L layout q quit"; frame.render_widget( Paragraph::new(text).style(Style::default().fg(FG_DIM)), area, @@ -236,6 +258,13 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect) { app.ensure_loaded(idx); app.pane_height = area.height as usize; + match app.layout { + AppLayout::Sbs => render_body_sbs(frame, app, area), + AppLayout::Inline => render_body_inline(frame, app, area), + } +} + +fn render_body_sbs(frame: &mut Frame, app: &mut App, area: Rect) { let left_w = area.width.saturating_sub(1) / 2; let right_w = area.width.saturating_sub(1).saturating_sub(left_w); let hlayout = Layout::default() @@ -335,6 +364,123 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect) { } } +/// Right-align `n` in a field of width `w`, or blank it out (`w` spaces) when there's no lineno +/// for this side — used by the inline gutter, which always reserves both the old and new lineno +/// columns even though a `Del`/`Add` row only fills one of them. +fn gutter_field(n: Option, w: usize) -> String { + match n { + Some(n) => format!("{n:>w$}"), + None => " ".repeat(w), + } +} + +/// Build a single rendered line for the inline layout's one full-width pane at a given +/// [`InlineRow`]. Context rows show BOTH the old and new lineno (there's a real line on each +/// side to number, and showing both matches the familiar unified-diff gutter convention); `Del` +/// rows show only the old-side column, `Add` rows only the new-side column — the other column is +/// blank rather than reused for anything, so a scan down the gutter reads as two honest, +/// independent line-number tracks. +fn build_inline_line( + view: &FileView, + row: &InlineRow, + word_spans: &[WordSpan], + old_gutter_w: usize, + new_gutter_w: usize, +) -> Line<'static> { + let (old_opt, new_opt, text, hl, kind) = match *row { + InlineRow::Context { old, new } => ( + Some(old), + Some(new), + view.new_line(new), + view.new_hl.as_ref().and_then(|v| v.get(new - 1)), + CellKind::Context, + ), + InlineRow::Del { old, .. } => ( + Some(old), + None, + view.old_line(old), + view.old_hl.as_ref().and_then(|v| v.get(old - 1)), + CellKind::Del, + ), + InlineRow::Add { new, .. } => ( + None, + Some(new), + view.new_line(new), + view.new_hl.as_ref().and_then(|v| v.get(new - 1)), + CellKind::Add, + ), + InlineRow::Gap { .. } => { + unreachable!("gap rows render via render_gap_row, not build_inline_line") + } + }; + + let gutter = format!( + "{} {} ", + gutter_field(old_opt, old_gutter_w), + gutter_field(new_opt, new_gutter_w) + ); + let mut spans = vec![TSpan::styled(gutter, Style::default().fg(FG_GUTTER))]; + + let is_word_pair = row.is_word_diff_pair(); + // `kind` is always Del/Add/Context here — inline has no Filler rows. + let emphasis = match kind { + CellKind::Del => Some((BG_DEL_SUBTLE, BG_DEL_STRONG)), + CellKind::Add => Some((BG_ADD_SUBTLE, BG_ADD_STRONG)), + CellKind::Context | CellKind::Filler => None, + }; + spans.extend(content_spans(text, hl, emphasis, word_spans, is_word_pair)); + Line::from(spans) +} + +fn render_body_inline(frame: &mut Frame, app: &mut App, area: Rect) { + let Some(view) = app.current_view_ref() else { + frame.render_widget(Paragraph::new("(failed to load file)"), area); + return; + }; + let old_gutter_w = gutter_width(view.old_line_count()); + let new_gutter_w = gutter_width(view.new_line_count()); + let scroll = app.scroll; + let pane_height = app.pane_height; + let end = (scroll + pane_height).min(view.inline.len()); + + // Same two-phase mutable/immutable dance as `render_body_sbs`, over the inline coordinate + // space instead. + if let Some(view) = app.current_view() { + for row_idx in scroll..end { + if matches!(view.inline.get(row_idx), Some(r) if r.is_word_diff_pair()) { + view.inline_word_spans_for_row(row_idx); + } + } + } + + let Some(view) = app.current_view_ref() else { + return; + }; + + for (i, row_idx) in (scroll..end).enumerate() { + let y = area.y + i as u16; + match &view.inline[row_idx] { + InlineRow::Gap { skipped } => { + render_gap_row(frame.buffer_mut(), area, y, *skipped); + } + row => { + let (old_spans, new_spans) = if row.is_word_diff_pair() { + view.peek_inline_word_spans(row_idx) + } else { + (Vec::new(), Vec::new()) + }; + let word_spans: &[WordSpan] = match row { + InlineRow::Del { .. } => &old_spans, + InlineRow::Add { .. } => &new_spans, + _ => &[], + }; + let line = build_inline_line(view, row, word_spans, old_gutter_w, new_gutter_w); + frame.buffer_mut().set_line(area.x, y, &line, area.width); + } + } + } +} + #[cfg(test)] mod tests { use ratatui::backend::TestBackend; @@ -519,4 +665,77 @@ mod tests { "expected renamed header with old_path @ base -> new_path, got: {header:?}" ); } + + #[test] + fn toggling_layout_reflows_the_same_fixture_and_toggling_back_restores_sbs() { + use crate::app::Layout; + + let old = "l1\nl2\nl3\nl4\nl5\nold word here\nl7\nl8\nl9\nl10\n"; + let new = "l1\nl2\nl3\nl4\nl5\nnew word here\nl7\nl8\nl9\nl10\n"; + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("small.txt", old, new) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + + // SBS: old and new side by side on the SAME row. + let sbs_buf = render_once(&mut app, 60, 20); + let sbs_content = buf_lines(&sbs_buf); + let sbs_row = sbs_content + .iter() + .position(|line| line.contains("old word here")) + .expect("SBS row pairs old and new on one row"); + assert!( + sbs_content[sbs_row].contains("new word here"), + "expected SBS to show del and add on the same row, got:\n{}", + sbs_content.join("\n") + ); + + app.toggle_layout(); + assert_eq!(app.layout, Layout::Inline); + + let inline_buf = render_once(&mut app, 60, 20); + let inline_content = buf_lines(&inline_buf); + let del_row = inline_content + .iter() + .position(|line| line.contains("old word here")) + .expect("inline shows the deleted line"); + let add_row = inline_content + .iter() + .position(|line| line.contains("new word here")) + .expect("inline shows the added line"); + assert!( + del_row < add_row, + "expected the inline del line above its paired add line, got:\n{}", + inline_content.join("\n") + ); + assert_ne!( + del_row, add_row, + "del and add must be on separate rows in inline layout" + ); + + // Toggling back re-renders SBS (single row again) rather than staying stuck in inline. + app.toggle_layout(); + assert_eq!(app.layout, Layout::Sbs); + let sbs_again = render_once(&mut app, 60, 20); + let sbs_again_content: Vec = (0..sbs_again.area.height) + .map(|y| { + (0..sbs_again.area.width) + .map(|x| cell_text(&sbs_again, x, y)) + .collect::() + }) + .collect(); + let row = sbs_again_content + .iter() + .position(|line| line.contains("old word here")) + .expect("SBS (again) row pairs old and new on one row"); + assert!( + sbs_again_content[row].contains("new word here"), + "expected toggling back to re-render SBS with del/add on one row, got:\n{}", + sbs_again_content.join("\n") + ); + } } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 3619b1d..dbb3efd 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -57,6 +57,7 @@ enum Action { PrevFile, NextHunk, PrevHunk, + ToggleLayout, None, } @@ -86,6 +87,7 @@ fn map_key(pending: &mut Option, key: KeyEvent, pane_height: usize) -> Act } KeyCode::Char('g') => Action::ScrollTop, KeyCode::Char('G') => Action::ScrollBottom, + KeyCode::Char('L') => Action::ToggleLayout, KeyCode::Tab => Action::NextFile, KeyCode::BackTab => Action::PrevFile, KeyCode::Char(']') => { @@ -111,6 +113,7 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::PrevFile => app.prev_file(), Action::NextHunk => app.next_hunk_row(), Action::PrevHunk => app.prev_hunk_row(), + Action::ToggleLayout => app.toggle_layout(), Action::None => {} } false @@ -246,6 +249,15 @@ mod tests { ); } + #[test] + fn shift_l_maps_to_toggle_layout() { + let mut pending = None; + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('L')), 20), + Action::ToggleLayout + ); + } + #[test] fn tab_and_backtab_map_to_file_nav() { let mut pending = None; From a92560a6a8e8ddec1465410d610c0ae275df2abc Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 19:49:28 -0400 Subject: [PATCH 026/203] docs(rfc): mark M3 renderer milestone done --- docs/rfc/workon-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 1e3c46a..7290167 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -129,7 +129,7 @@ evidence, not to the conclusion. - **M0 — workspace plumbing.** New member crate `git-workon-review` (lib+bin, clap, error model matching workspace: thiserror+miette). Toolchain bump (ratatui/tree-sitter won't meet 1.68.2; resolved: workspace-wide `rust-version = 1.88` — no crate had ever inherited the old value, so there was no lib MSRV to preserve). Lib hygiene (drop unused dialoguer/env_logger). CI: tree-sitter C builds. Release posture per [ADR-027](../adr/027-review-crate-workspace-placement.md): `publish = false` keeps the crate out of release-plz and cargo-dist entirely; release-plz wiring is deliberately deferred to the M3 flip — do NOT add a release-plz.toml entry in M0. Acceptance: `cargo build --workspace` green, empty `git-workon-review` binary runs and prints help. - **M1 — fixture extensions + lib stack capabilities (test-first).** Fixture: sqlite metadata mode (also finally exercises the lib's primary read path), index-state builders. Lib: `parentBranchRevision` read (both formats) + needs-restack; git-inference StackModel; changeset assembly API (`Vec {branch, base_ref, head_ref, title, current, needs_restack}` + uncommitted layer). Acceptance: existing lib tests green + new capabilities spec'd against fixtures in both metadata formats. - **M2 — trap corpus port.** Diff parser + patch synthesis in the review lib, the six trap items as tests, git2-vs-CLI verdict rendered (and the write-path decision recorded here). Acceptance: round-trip corpus green against real repos. — DONE (2026-07-06): corpus green on both backends; verdict recorded above. -- **M3 — renderer + uncommitted source.** Port spike modules; wire changeset → parsed diff → SBS/inline render; file nav; the uncommitted source end-to-end. Acceptance: dogfood-able read-only review of a dirty worktree. +- **M3 — renderer + uncommitted source.** Port spike modules; wire changeset → parsed diff → SBS/inline render; file nav; the uncommitted source end-to-end. Acceptance: dogfood-able read-only review of a dirty worktree. — DONE (2026-07-06): combined-zoom read-only review with SBS + inline layouts, collapsed context gaps, word-diff emphasis, tree-sitter highlighting (spike's 8 grammars; syntect deferred), file/hunk nav; dogfooded against a dirty worktree. Port note: the spike's `compose_segments` had a latent first-match span-precedence bug that silently dropped word-level emphasis — fixed here (reverse-order lookup), pinned by a three-way bg test in `render.rs`. - **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. - **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). - **M6 — comments + integration.** Comment store + `mcp` subcommand; `$NVIM`/`$EDITOR` edit jump; git-workon external dispatch + completion delegation. Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review. From cce9916101e04e20394b4fe6337684cc376099e4 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 23:37:34 -0400 Subject: [PATCH 027/203] docs(rfc): lock M4 staging and zoom design --- docs/rfc/workon-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 7290167..0100f97 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -130,7 +130,7 @@ evidence, not to the conclusion. - **M1 — fixture extensions + lib stack capabilities (test-first).** Fixture: sqlite metadata mode (also finally exercises the lib's primary read path), index-state builders. Lib: `parentBranchRevision` read (both formats) + needs-restack; git-inference StackModel; changeset assembly API (`Vec {branch, base_ref, head_ref, title, current, needs_restack}` + uncommitted layer). Acceptance: existing lib tests green + new capabilities spec'd against fixtures in both metadata formats. - **M2 — trap corpus port.** Diff parser + patch synthesis in the review lib, the six trap items as tests, git2-vs-CLI verdict rendered (and the write-path decision recorded here). Acceptance: round-trip corpus green against real repos. — DONE (2026-07-06): corpus green on both backends; verdict recorded above. - **M3 — renderer + uncommitted source.** Port spike modules; wire changeset → parsed diff → SBS/inline render; file nav; the uncommitted source end-to-end. Acceptance: dogfood-able read-only review of a dirty worktree. — DONE (2026-07-06): combined-zoom read-only review with SBS + inline layouts, collapsed context gaps, word-diff emphasis, tree-sitter highlighting (spike's 8 grammars; syntect deferred), file/hunk nav; dogfooded against a dirty worktree. Port note: the spike's `compose_segments` had a latent first-match span-precedence bug that silently dropped word-level emphasis — fixed here (reverse-order lookup), pinned by a three-way bg test in `render.rs`. -- **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. +- **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. Design locked 2026-07-06 (plan artifact `iron-lattice`): (1) staging = prototype parity — verbs act only in unstaged/staged panes, combined refuses, direction = pane role (combined-native toggle deferred); (2) cursor-primary nav in all views, scroll derived; (3) full 4-state zoom (`split→combined→unstaged→staged`) with per-file `_gate` downgrade and stacked split panes (per-pane cursor, `w` focus), no collapse debounce; (4) runtime stays sync — poll `IndexSignature` on Tick, synchronous re-diff (no threads/notify dep); (5) queue enqueue+drain same beat, refresh, re-snapshot; (6) footer-swap for refusals/errors + discard confirm; (7) attribution via a new pure `attribute.rs` (membership sets keyed by lnum); (8) line selection in both layouts (inline one-sided, SBS row-pair). Six changesets `m4-cursor → m4-zoom → m4-attribute → m4-notify → m4-staging → m4-watch`. - **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). - **M6 — comments + integration.** Comment store + `mcp` subcommand; `$NVIM`/`$EDITOR` edit jump; git-workon external dispatch + completion delegation. Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review. From 54106e355d247ed43c3611485555991281f9947d Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 6 Jul 2026 23:51:01 -0400 Subject: [PATCH 028/203] feat(review): make cursor drive navigation and scroll --- git-workon-review/src/app.rs | 337 +++++++++++++++++++++++++------- git-workon-review/src/render.rs | 190 +++++++++++++++++- git-workon-review/src/tui.rs | 29 +-- 3 files changed, 473 insertions(+), 83 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 7d84d53..0e7794a 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -19,6 +19,10 @@ use crate::highlight::{FgSpan, TsHighlighter}; use crate::model::{DiffModel, FileChange, FileStatus}; use crate::wordiff::{word_diff_spans, Span}; +/// Minimum rows kept between the cursor and the top/bottom of the pane while scrolling — see +/// [`App::derive_scroll`]. +const SCROLLOFF: usize = 2; + /// Loaded, aligned, highlighted view of one file's combined diff. /// /// Full text is read once per side, from whichever source the file's status says still exists: @@ -44,8 +48,13 @@ pub struct FileView { /// any surviving row is unchanged. pub display: Vec, /// Index into [`Self::display`] of the first hunk's first row (or 0 for a file with no - /// hunks), for the initial scroll jump. + /// hunks), for the initial cursor jump under [`Layout::Sbs`]. pub first_hunk_row: usize, + /// Inline-layout analog of [`Self::first_hunk_row`]: index into [`Self::inline`] instead of + /// `display`. A separate field (rather than translating one into the other) because the two + /// row vectors track the same content in different shapes — same rationale as the two + /// word-span caches below. + pub first_inline_hunk_row: usize, pub old_hl: Option>>, pub new_hl: Option>>, /// Lazily computed word-diff spans, keyed by DISPLAY row index — the only coordinate the @@ -98,6 +107,10 @@ impl FileView { let old_hl = ts.highlight_file(old_source_path, &old_text); let new_hl = ts.highlight_file(&file.path, &new_text); let inline = inline_rows(&display); + let first_inline_hunk_row = inline + .iter() + .position(is_inline_hunk_content_row) + .unwrap_or(0); Self { old_text, @@ -106,6 +119,7 @@ impl FileView { new_lines, display, first_hunk_row, + first_inline_hunk_row, old_hl, new_hl, word_spans: HashMap::new(), @@ -257,6 +271,13 @@ pub struct App { pub files: Vec, views: Vec>, pub current: usize, + /// Row index, in the ACTIVE layout's coordinate space, of the highlighted navigation + /// anchor — THE nav state (locked decision #2 in the M4 plan). `scroll` is derived from + /// this every time it moves, via [`Self::derive_scroll`]. + pub cursor: usize, + /// Top-of-viewport row index, in the active layout's space. Read directly by the renderer, + /// but never written except by [`Self::derive_scroll`] — every cursor-moving method ends by + /// calling it, so `scroll` always reflects the CURRENT `cursor`. pub scroll: usize, pub pane_height: usize, /// Label for the old side of the diff, shown next to a rename's `old_path` in the header. @@ -276,6 +297,7 @@ impl App { files: combined.files, views: (0..n).map(|_| None).collect(), current: 0, + cursor: 0, scroll: 0, pane_height: 20, base_label: "HEAD".to_string(), @@ -320,18 +342,26 @@ impl App { self.views.get(self.current).and_then(|v| v.as_ref()) } - /// Jump the scroll position to the current file's first hunk (or the top, for a file with - /// no hunks or that isn't loaded yet). + /// Jump the cursor to the current file's first hunk (or row 0, for a file with no hunks or + /// that isn't loaded yet), then re-derive `scroll`. Reads whichever of `FileView`'s two + /// first-hunk fields matches [`Self::layout`] — see [`FileView::first_inline_hunk_row`]'s + /// doc comment for why the SBS and inline positions are separate fields, not one translated + /// into the other. pub fn jump_to_first_hunk(&mut self) { - self.scroll = self + let layout = self.layout; + self.cursor = self .views .get(self.current) .and_then(|v| v.as_ref()) - .map(|v| v.first_hunk_row) + .map(|v| match layout { + Layout::Sbs => v.first_hunk_row, + Layout::Inline => v.first_inline_hunk_row, + }) .unwrap_or(0); + self.derive_scroll(); } - /// Load the current file (if not binary) and jump to its first hunk. + /// Load the current file (if not binary) and jump the cursor to its first hunk. pub fn open_current(&mut self) { self.ensure_loaded(self.current); self.jump_to_first_hunk(); @@ -366,74 +396,124 @@ impl App { self.row_count().saturating_sub(self.pane_height.max(1)) } - /// Relative scroll, clamped to `[0, max_scroll()]` — except it never snaps backward past - /// the current position when that position is itself beyond `max_scroll()` (e.g. right - /// after [`Self::next_hunk_row`]/[`Self::prev_hunk_row`] jumped the hunk to the top of a - /// pane taller than the remaining display). A relative scroll past a jump-placed position - /// is a no-op in the over-scrolled direction rather than a backward leap; scrolling back the - /// other way still works normally. - pub fn scroll_by(&mut self, delta: i64) { - let max = self.max_scroll() as i64; - let cur = self.scroll as i64; - self.scroll = (cur + delta).clamp(0, max.max(cur)) as usize; + /// Clamp `cursor` into `[0, row_count() - 1]` (or `0` for an empty row list) — used after an + /// operation that can leave `cursor` referring to a row the active layout/file no longer has + /// (a layout toggle, most notably; see [`Self::toggle_layout`]). + fn clamp_cursor(&mut self) { + let rows = self.row_count(); + self.cursor = if rows == 0 { + 0 + } else { + self.cursor.min(rows - 1) + }; + } + + /// Re-derive `scroll` from `cursor` so the cursor stays visible: it keeps `cursor` within + /// `[scroll + SCROLLOFF, scroll + pane_height - 1 - SCROLLOFF]` by sliding `scroll` the + /// MINIMUM amount needed (never re-centering) — the familiar vim `scrolloff` behavior. Near + /// a file edge, where honoring both margins at once isn't possible, the edge wins: the final + /// clamp to `[0, max_scroll()]` lets `cursor` reach row 0 or the last row even though the + /// margin can't be kept there. Every cursor-moving method ends by calling this — `scroll` is + /// otherwise never written directly. + fn derive_scroll(&mut self) { + let rows = self.row_count(); + if rows == 0 { + self.scroll = 0; + return; + } + let pane_height = self.pane_height.max(1); + let cursor = self.cursor.min(rows - 1); + let bottom_margin = pane_height.saturating_sub(1).saturating_sub(SCROLLOFF); + + let mut scroll = self.scroll; + if cursor < scroll + SCROLLOFF { + scroll = cursor.saturating_sub(SCROLLOFF); + } else if cursor > scroll + bottom_margin { + scroll = cursor.saturating_sub(bottom_margin); + } + self.scroll = scroll.min(self.max_scroll()); + } + + /// Move the cursor by `delta` rows, clamped to `[0, row_count() - 1]` (a no-op on an empty + /// file list), then re-derive `scroll`. Drives `j`/`k`/arrows (`delta = ±1`) and + /// `Ctrl-d`/`Ctrl-u` (`delta = ±pane_height/2`). + pub fn move_cursor_by(&mut self, delta: i64) { + let rows = self.row_count(); + if rows == 0 { + self.cursor = 0; + self.scroll = 0; + return; + } + let max = (rows - 1) as i64; + let cur = self.cursor as i64; + self.cursor = (cur + delta).clamp(0, max) as usize; + self.derive_scroll(); } + /// Move the cursor to row 0 (`g`) and re-derive `scroll`. pub fn scroll_top(&mut self) { - self.scroll = 0; + self.cursor = 0; + self.derive_scroll(); } + /// Move the cursor to the last row (`G`) and re-derive `scroll`. pub fn scroll_bottom(&mut self) { - self.scroll = self.max_scroll(); + let rows = self.row_count(); + self.cursor = rows.saturating_sub(1); + self.derive_scroll(); } - /// Scroll to the next hunk-start row after the current scroll position (`]h`). A no-op if - /// there is no later hunk, or the current file has no loaded view. Searches [`Self::layout`]'s - /// own row vector and coordinate space — `scroll` is an index into `display` under - /// [`Layout::Sbs`] but into `inline` under [`Layout::Inline`], and the two disagree on row - /// count/position whenever a change block has unequal del/add counts. + /// Move the cursor to the next hunk-start row after its current position (`]h`), then + /// re-derive `scroll`. A no-op if there is no later hunk, or the current file has no loaded + /// view. Searches [`Self::layout`]'s own row vector and coordinate space — `cursor` is an + /// index into `display` under [`Layout::Sbs`] but into `inline` under [`Layout::Inline`], and + /// the two disagree on row count/position whenever a change block has unequal del/add + /// counts. pub fn next_hunk_row(&mut self) { let Some(view) = self.current_view_ref() else { return; }; let next = match self.layout { - Layout::Sbs => find_next_hunk_row(&view.display, self.scroll), - Layout::Inline => find_next_inline_hunk_row(&view.inline, self.scroll), + Layout::Sbs => find_next_hunk_row(&view.display, self.cursor), + Layout::Inline => find_next_inline_hunk_row(&view.inline, self.cursor), }; if let Some(row) = next { - self.scroll = row; + self.cursor = row; + self.derive_scroll(); } } - /// Scroll to the previous hunk-start row before the current scroll position (`[h`). A no-op - /// if there is no earlier hunk, or the current file has no loaded view. See - /// [`Self::next_hunk_row`] for why the search dispatches on [`Self::layout`]. + /// Move the cursor to the previous hunk-start row before its current position (`[h`), then + /// re-derive `scroll`. A no-op if there is no earlier hunk, or the current file has no loaded + /// view. See [`Self::next_hunk_row`] for why the search dispatches on [`Self::layout`]. pub fn prev_hunk_row(&mut self) { let Some(view) = self.current_view_ref() else { return; }; let prev = match self.layout { - Layout::Sbs => find_prev_hunk_row(&view.display, self.scroll), - Layout::Inline => find_prev_inline_hunk_row(&view.inline, self.scroll), + Layout::Sbs => find_prev_hunk_row(&view.display, self.cursor), + Layout::Inline => find_prev_inline_hunk_row(&view.inline, self.cursor), }; if let Some(row) = prev { - self.scroll = row; + self.cursor = row; + self.derive_scroll(); } } /// Toggle between side-by-side and inline layouts (`L`). Deliberately does not try to - /// re-derive an equivalent `scroll` position for the new layout — the two layouts' row - /// vectors track the same underlying content in a different shape, and translating exactly - /// isn't worth the complexity for M3; the user re-orients same as they would after a resize. - /// It DOES clamp `scroll` to the new layout's `max_scroll()`, though: inline is strictly - /// taller than SBS whenever paired del/add blocks exist, so a scroll position picked up - /// there (including an over-scrolled hunk-jump position, see [`Self::scroll_by`]) can exceed - /// SBS's shorter range. + /// re-derive an exactly equivalent `cursor` position for the new layout — the two layouts' + /// row vectors track the same underlying content in a different shape, and translating + /// exactly isn't worth the complexity for M4; the user re-orients same as they would after a + /// resize. It DOES clamp `cursor` to the new layout's `row_count()` (see + /// [`Self::clamp_cursor`]) and re-derive `scroll` from it, so the result is always a valid, + /// visible position even though it isn't a semantic equivalent of the old one. pub fn toggle_layout(&mut self) { self.layout = match self.layout { Layout::Sbs => Layout::Inline, Layout::Inline => Layout::Sbs, }; - self.scroll = self.scroll.min(self.max_scroll()); + self.clamp_cursor(); + self.derive_scroll(); } } @@ -681,7 +761,7 @@ mod tests { } #[test] - fn app_next_and_prev_hunk_row_scroll_between_hunks() { + fn app_next_and_prev_hunk_row_move_cursor_between_hunks() { let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .unstaged_file( @@ -694,38 +774,43 @@ mod tests { let mut app = app_from_fixture(&fixture); app.open_current(); - let first_hunk_row = app.scroll; + let first_hunk_row = app.cursor; app.next_hunk_row(); assert!( - app.scroll > first_hunk_row, - "should scroll to the later hunk" + app.cursor > first_hunk_row, + "should move the cursor to the later hunk" ); - let second_hunk_row = app.scroll; + let second_hunk_row = app.cursor; // No hunk after the last one: no-op. app.next_hunk_row(); - assert_eq!(app.scroll, second_hunk_row); + assert_eq!(app.cursor, second_hunk_row); app.prev_hunk_row(); - assert_eq!(app.scroll, first_hunk_row); + assert_eq!(app.cursor, first_hunk_row); // No hunk before the first one: no-op. app.prev_hunk_row(); - assert_eq!(app.scroll, first_hunk_row); + assert_eq!(app.cursor, first_hunk_row); } #[test] - fn scroll_by_does_not_snap_backward_past_a_hunk_jump() { - // A small file (fits in the default pane height) with two hunks: `max_scroll() == 0`, - // but `next_hunk_row` still jumps `scroll` to the second hunk's row unclamped, leaving - // the view over-scrolled relative to `max_scroll()`. + fn cursor_move_after_hunk_jump_is_sane_when_whole_file_fits_one_pane() { + // A small file (fits in the default pane height) with two hunks: `max_scroll() == 0`. + // Under the OLD scroll-primary model (M3), `next_hunk_row` jumped raw `scroll` to the + // second hunk's row unclamped, over-scrolling past `max_scroll()`, and `scroll_by` had a + // "don't snap backward" carve-out just to keep that over-scrolled position sane on the + // very next relative move. The cursor-primary model makes the carve-out unnecessary: + // `scroll` is *derived* from `cursor` and `max_scroll()`, so it's simply pinned to 0 for + // a file that fits in one pane — there's nothing to snap back from, and `cursor` itself + // just moves normally. let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .unstaged_file( "small.txt", - "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n", - "1\nCHANGED\n3\n4\n5\n6\n7\n8\n9\nCHANGED_TOO\n", + "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n", + "1\nCHANGED\n3\n4\n5\n6\n7\n8\n9\nCHANGED_TOO\n11\n", ) .build() .unwrap(); @@ -734,20 +819,125 @@ mod tests { app.open_current(); assert_eq!(app.max_scroll(), 0, "whole file must fit in one pane"); + let first_hunk_cursor = app.cursor; app.next_hunk_row(); - let over_scrolled = app.scroll; + let second_hunk_cursor = app.cursor; assert!( - over_scrolled > 0, - "hunk jump should place scroll past max_scroll" + second_hunk_cursor > first_hunk_cursor, + "hunk jump should move the cursor forward" + ); + assert_eq!( + app.scroll, 0, + "scroll stays pinned to 0 — the whole file already fits in the pane" + ); + + let last_row = app.current_view_ref().unwrap().display.len() - 1; + + // `j` (cursor +1) after the hunk jump moves the cursor further, not backward. + app.move_cursor_by(1); + assert_eq!(app.cursor, (second_hunk_cursor + 1).min(last_row)); + assert_eq!(app.scroll, 0); + + // `k` (cursor -1) moves back up normally — no special-cased snap needed anymore. + app.move_cursor_by(-1); + assert_eq!(app.cursor, second_hunk_cursor); + assert_eq!(app.scroll, 0); + } + + #[test] + fn move_cursor_by_clamps_at_file_edges() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("small.txt", "1\n2\n3\n4\n5\n", "1\nCHANGED\n3\n4\n5\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + let last_row = app.current_view_ref().unwrap().display.len() - 1; + + app.move_cursor_by(-1000); + assert_eq!(app.cursor, 0, "cursor must clamp at row 0, not go negative"); + + app.move_cursor_by(1000); + assert_eq!( + app.cursor, last_row, + "cursor must clamp at the last row, not run past it" + ); + } + + #[test] + fn derive_scroll_keeps_scrolloff_margin_and_slides_minimally() { + // 40 single-line rows, no changes worth hunk-jumping over — this test is purely about + // the cursor/scroll follow relationship, not hunk content. + let lines: String = (1..=40).map(|n| format!("l{n}\n")).collect(); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("big.txt", &lines) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.ensure_loaded(0); + app.pane_height = 10; + app.cursor = 0; + app.scroll = 0; + + // Bottom margin is `pane_height - 1 - SCROLLOFF` = 7: the cursor can move down to row 7 + // without `scroll` moving at all. + app.move_cursor_by(7); + assert_eq!(app.cursor, 7); + assert_eq!( + app.scroll, 0, + "scroll must not move before the cursor hits the margin" + ); + + // One more step crosses the bottom margin: scroll slides by exactly 1, the minimum + // needed to keep the cursor `SCROLLOFF` rows from the bottom — not a re-center. + app.move_cursor_by(1); + assert_eq!(app.cursor, 8); + assert_eq!( + app.scroll, 1, + "scroll should slide by the minimum amount, not re-center" + ); + + // Scrolling back up mirrors the same margin on the top edge (SCROLLOFF = 2): with + // `scroll` at 1, the cursor can move back up to `scroll + SCROLLOFF` = 3 before `scroll` + // follows. + app.move_cursor_by(-5); + assert_eq!(app.cursor, 3); + assert_eq!( + app.scroll, 1, + "scroll must not move while the cursor is still within the top margin" + ); + app.move_cursor_by(-1); + assert_eq!(app.cursor, 2); + assert_eq!( + app.scroll, 0, + "scroll should slide back by the minimum amount once the cursor crosses the top margin" ); + } + + #[test] + fn move_cursor_by_on_empty_file_list_does_not_panic() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + assert!(app.files.is_empty(), "fixture must have no dirty files"); - // `j` (scroll_by(1)) at an over-scrolled position is a no-op, not a backward snap. - app.scroll_by(1); - assert_eq!(app.scroll, over_scrolled); + app.move_cursor_by(5); + app.move_cursor_by(-5); + app.scroll_top(); + app.scroll_bottom(); + app.next_hunk_row(); + app.prev_hunk_row(); + app.toggle_layout(); - // `k` (scroll_by(-1)) still scrolls up normally. - app.scroll_by(-1); - assert_eq!(app.scroll, over_scrolled - 1); + assert_eq!(app.cursor, 0); + assert_eq!(app.scroll, 0); } #[test] @@ -863,10 +1053,11 @@ mod tests { let mut app = app_from_fixture(&fixture); app.ensure_loaded(0); app.layout = Layout::Inline; + app.cursor = 0; app.scroll = 0; app.next_hunk_row(); - let first_block_row = app.scroll; + let first_block_row = app.cursor; assert!( matches!( app.current_view_ref().unwrap().inline[first_block_row], @@ -876,7 +1067,7 @@ mod tests { ); app.next_hunk_row(); - let second_block_row = app.scroll; + let second_block_row = app.cursor; assert!( second_block_row > first_block_row, "should jump to the later change block" @@ -888,13 +1079,13 @@ mod tests { app.prev_hunk_row(); assert_eq!( - app.scroll, first_block_row, + app.cursor, first_block_row, "prev_hunk_row should return to the earlier block" ); } #[test] - fn toggle_layout_clamps_out_of_range_scroll() { + fn toggle_layout_clamps_cursor_and_rederives_scroll() { use super::Layout; let fixture = FixtureBuilder::new() @@ -909,9 +1100,21 @@ mod tests { app.pane_height = 2; app.scroll_bottom(); assert!(app.scroll > 0, "inline scroll should be over the SBS max"); + let inline_last_row = app.cursor; app.toggle_layout(); assert_eq!(app.layout, Layout::Sbs, "toggling back returns to Sbs"); + let sbs_rows = app.current_view_ref().unwrap().display.len(); + assert!( + app.cursor < sbs_rows, + "cursor must be clamped into the new (shorter) SBS row range, was {} of {}", + app.cursor, + sbs_rows + ); + assert!( + app.cursor <= inline_last_row, + "clamping should only ever pull the cursor down, never push it further out" + ); assert!( app.scroll <= app.max_scroll(), "scroll must be clamped to the new layout's max_scroll" diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 266c46f..ac48147 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -25,6 +25,47 @@ const BG_ADD_STRONG: Color = Color::Rgb(32, 100, 48); const FG_DEFAULT: Color = Color::Gray; const FG_DIM: Color = Color::DarkGray; const FG_GUTTER: Color = Color::DarkGray; +/// Tint blended into the cursor row's background (see [`blend_bg`]) — a cool slate-blue, chosen +/// to read as "cursor here" without competing with the warm del/add hues above. +const BG_CURSOR: Color = Color::Rgb(45, 50, 90); + +/// Blend the cursor row's tint into an existing background, so the cursor highlight composites +/// with (rather than replaces) del/add/word-diff emphasis on the same row — the row highlight is +/// a wash over the whole row, not a mask. `None` (a context/gap cell with no bg span at all) +/// resolves to the tint directly, since there's nothing to blend against. Non-RGB colors +/// shouldn't occur here (every bg constant in this module is `Color::Rgb`) but pass through +/// unblended rather than panicking if one ever does. +fn blend_bg(base: Option, tint: Color) -> Color { + match (base, tint) { + (Some(Color::Rgb(r, g, b)), Color::Rgb(tr, tg, tb)) => { + // 60% of the original emphasis, 40% tint — enough to read as a distinct highlight + // without washing out del/add/word-diff coloring underneath it. + let mix = |c: u8, t: u8| ((u16::from(c) * 3 + u16::from(t) * 2) / 5) as u8; + Color::Rgb(mix(r, tr), mix(g, tg), mix(b, tb)) + } + (Some(other), _) => other, + (None, tint) => tint, + } +} + +/// Apply the cursor row's highlight to an already-built line: blend [`BG_CURSOR`] into every +/// span's background (see [`blend_bg`]), then pad the line out to `width` with solid tint so the +/// highlight covers the full row even past the line's own rendered content (a short line, or one +/// pane of a filler/deleted-file row, would otherwise leave the tail of the row unhighlighted). +fn apply_cursor_row(mut line: Line<'static>, width: u16) -> Line<'static> { + for span in &mut line.spans { + let bg = blend_bg(span.style.bg, BG_CURSOR); + span.style = span.style.bg(bg); + } + let used = line.width() as u16; + if used < width { + line.spans.push(TSpan::styled( + " ".repeat((width - used) as usize), + Style::default().bg(BG_CURSOR), + )); + } + line +} /// One resolved (bg, fg) pair for a byte range of a line. struct Segment { @@ -236,9 +277,14 @@ fn render_footer(frame: &mut Frame, area: Rect) { /// Write a gap row's `··· N unchanged lines ···` 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. -fn render_gap_row(buf: &mut Buffer, area: Rect, y: u16, skipped: usize) { +fn render_gap_row(buf: &mut Buffer, area: Rect, y: u16, skipped: usize, is_cursor: bool) { let msg = format!("··· {skipped} unchanged lines ···"); let line = Line::from(TSpan::styled(msg, Style::default().fg(FG_DIM))); + let line = if is_cursor { + apply_cursor_row(line, area.width) + } else { + line + }; buf.set_line(area.x, y, &line, area.width); } @@ -317,9 +363,10 @@ fn render_body_sbs(frame: &mut Frame, app: &mut App, area: Rect) { for (i, row_idx) in (scroll..end).enumerate() { let y = area.y + i as u16; + let is_cursor = row_idx == app.cursor; match &view.display[row_idx] { DisplayRow::Gap { skipped } => { - render_gap_row(frame.buffer_mut(), area, y, *skipped); + render_gap_row(frame.buffer_mut(), area, y, *skipped, is_cursor); } DisplayRow::Row(row) => { let is_pair = row.is_word_diff_pair(); @@ -353,12 +400,31 @@ fn render_body_sbs(frame: &mut Frame, app: &mut App, area: Rect) { new_gutter_w, new_area.width as usize, ); + let (old_line, new_line) = if is_cursor { + ( + apply_cursor_row(old_line, old_area.width), + apply_cursor_row(new_line, new_area.width), + ) + } else { + (old_line, new_line) + }; frame .buffer_mut() .set_line(old_area.x, y, &old_line, old_area.width); frame .buffer_mut() .set_line(new_area.x, y, &new_line, new_area.width); + // 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`. + if is_cursor { + frame.buffer_mut().set_string( + div_area.x, + y, + "│", + Style::default().fg(FG_DIM).bg(BG_CURSOR), + ); + } } } } @@ -459,9 +525,10 @@ fn render_body_inline(frame: &mut Frame, app: &mut App, area: Rect) { for (i, row_idx) in (scroll..end).enumerate() { let y = area.y + i as u16; + let is_cursor = row_idx == app.cursor; match &view.inline[row_idx] { InlineRow::Gap { skipped } => { - render_gap_row(frame.buffer_mut(), area, y, *skipped); + render_gap_row(frame.buffer_mut(), area, y, *skipped, is_cursor); } row => { let (old_spans, new_spans) = if row.is_word_diff_pair() { @@ -475,6 +542,11 @@ fn render_body_inline(frame: &mut Frame, app: &mut App, area: Rect) { _ => &[], }; let line = build_inline_line(view, row, word_spans, old_gutter_w, new_gutter_w); + let line = if is_cursor { + apply_cursor_row(line, area.width) + } else { + line + }; frame.buffer_mut().set_line(area.x, y, &line, area.width); } } @@ -490,6 +562,7 @@ mod tests { use git_workon_fixture::prelude::*; use super::render; + use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; use crate::app::App; @@ -738,4 +811,115 @@ mod tests { sbs_again_content.join("\n") ); } + + #[test] + fn cursor_row_carries_a_distinct_bg_tint_in_both_sbs_panes() { + // Two plain context rows (l10, l11) well clear of the hunk's own del/add emphasis — the + // cursor tint must be visible on its own, not riding on top of an already-colored row. + 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) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + + // Move the cursor onto the context row that will render as "l10 " — its display index + // is the row whose old-side line number is 10. + let cursor_row = app + .current_view_ref() + .unwrap() + .display + .iter() + .position(|row| matches!(row, DisplayRow::Row(r) if r.old == Row::Line(10))) + .expect("l10 row present in the display vector"); + app.cursor = cursor_row; + + let buf = render_once(&mut app, 60, 20); + let content = buf_lines(&buf); + + let cursor_y = content + .iter() + .position(|line| line.contains("l10 ")) + .expect("cursor row (l10) visible") as u16; + // A DIFFERENT context row, not under the cursor, for comparison — same row "kind" + // (plain context) so any bg difference is attributable to the cursor tint alone. + let other_y = content + .iter() + .position(|line| line.contains("l11 ")) + .expect("comparison context row (l11) visible") as u16; + + // Left (old) pane: gutter column (x=1) is well inside both the gutter and content. + let left_cursor_bg = buf.cell((1, cursor_y)).unwrap().style().bg; + let left_other_bg = buf.cell((1, other_y)).unwrap().style().bg; + assert_ne!( + left_cursor_bg, left_other_bg, + "expected the cursor row's LEFT pane to carry a background distinct from a \ + non-cursor context row" + ); + + // Right (new) pane: same check, at a column past the divider. + let left_w = (buf.area.width.saturating_sub(1)) / 2; + let right_x = left_w + 2; // skip the divider column, land inside the right pane's gutter + let right_cursor_bg = buf.cell((right_x, cursor_y)).unwrap().style().bg; + let right_other_bg = buf.cell((right_x, other_y)).unwrap().style().bg; + assert_ne!( + right_cursor_bg, right_other_bg, + "expected the cursor row's RIGHT pane to carry a background distinct from a \ + non-cursor context row too" + ); + + // The single `│` divider column between the panes must ALSO carry the cursor wash — + // otherwise a dark seam splits the highlight down the middle of every SBS cursor row. + let divider_x = left_w; + assert_eq!( + cell_text(&buf, divider_x, cursor_y), + "│", + "sanity: located the divider column between the two panes" + ); + assert_eq!( + buf.cell((divider_x, cursor_y)).unwrap().style().bg, + Some(super::BG_CURSOR), + "expected the cursor row's DIVIDER cell to carry the cursor background, not the \ + default — otherwise the highlight has a seam through the middle" + ); + } + + #[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 + // 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"; + 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) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + + let buf = render_once(&mut app, 60, 20); + let content = buf_lines(&buf); + let changed_row_y = content + .iter() + .position(|line| line.contains("old word here")) + .expect("changed row present") as u16; + + // Gutter width 3 + 1 space = column 4 is where "old" (the changed word) starts; column 8 + // is the unchanged remainder of the same line ("word here"). + let word_bg = buf.cell((4, changed_row_y)).unwrap().style().bg; + 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 \ + own row" + ); + } } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index dbb3efd..9638e69 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -50,7 +50,7 @@ pub fn next_event(timeout: Duration) -> io::Result> { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Action { Quit, - ScrollBy(i64), + MoveCursorBy(i64), ScrollTop, ScrollBottom, NextFile, @@ -77,13 +77,13 @@ fn map_key(pending: &mut Option, key: KeyEvent, pane_height: usize) -> Act match key.code { KeyCode::Char('q') | KeyCode::Esc => Action::Quit, - KeyCode::Char('j') | KeyCode::Down => Action::ScrollBy(1), - KeyCode::Char('k') | KeyCode::Up => Action::ScrollBy(-1), + KeyCode::Char('j') | KeyCode::Down => Action::MoveCursorBy(1), + KeyCode::Char('k') | KeyCode::Up => Action::MoveCursorBy(-1), KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { - Action::ScrollBy((pane_height / 2).max(1) as i64) + Action::MoveCursorBy((pane_height / 2).max(1) as i64) } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - Action::ScrollBy(-((pane_height / 2).max(1) as i64)) + Action::MoveCursorBy(-((pane_height / 2).max(1) as i64)) } KeyCode::Char('g') => Action::ScrollTop, KeyCode::Char('G') => Action::ScrollBottom, @@ -106,7 +106,7 @@ fn map_key(pending: &mut Option, key: KeyEvent, pane_height: usize) -> Act fn apply_action(app: &mut App, action: Action) -> bool { match action { Action::Quit => return true, - Action::ScrollBy(delta) => app.scroll_by(delta), + Action::MoveCursorBy(delta) => app.move_cursor_by(delta), Action::ScrollTop => app.scroll_top(), Action::ScrollBottom => app.scroll_bottom(), Action::NextFile => app.next_file(), @@ -205,19 +205,19 @@ mod tests { let mut pending = None; assert_eq!( map_key(&mut pending, key(KeyCode::Char('j')), 20), - Action::ScrollBy(1) + Action::MoveCursorBy(1) ); assert_eq!( map_key(&mut pending, key(KeyCode::Down), 20), - Action::ScrollBy(1) + Action::MoveCursorBy(1) ); assert_eq!( map_key(&mut pending, key(KeyCode::Char('k')), 20), - Action::ScrollBy(-1) + Action::MoveCursorBy(-1) ); assert_eq!( map_key(&mut pending, key(KeyCode::Up), 20), - Action::ScrollBy(-1) + Action::MoveCursorBy(-1) ); } @@ -226,14 +226,17 @@ mod tests { let mut pending = None; assert_eq!( map_key(&mut pending, ctrl_key('d'), 21), - Action::ScrollBy(10) + Action::MoveCursorBy(10) ); assert_eq!( map_key(&mut pending, ctrl_key('u'), 21), - Action::ScrollBy(-10) + Action::MoveCursorBy(-10) ); // A pane height of 1 still scrolls by at least one line. - assert_eq!(map_key(&mut pending, ctrl_key('d'), 1), Action::ScrollBy(1)); + assert_eq!( + map_key(&mut pending, ctrl_key('d'), 1), + Action::MoveCursorBy(1) + ); } #[test] From 9d1c50a3e857e2d6f1b947775b4f12852fa39a63 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 00:17:43 -0400 Subject: [PATCH 029/203] feat(review): add zoom states with gate matrix and split panes --- git-workon-review/src/app.rs | 841 +++++++++++++++++++++++++++++--- git-workon-review/src/main.rs | 9 +- git-workon-review/src/render.rs | 260 +++++++++- git-workon-review/src/tui.rs | 19 + 4 files changed, 1023 insertions(+), 106 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 0e7794a..ff8caa4 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -14,6 +14,7 @@ use std::path::Path; use git2::Repository; +use crate::acquire::WorktreeDiffs; use crate::align::{align_file, collapse_gaps, inline_rows, CellKind, DisplayRow, InlineRow, Row}; use crate::highlight::{FgSpan, TsHighlighter}; use crate::model::{DiffModel, FileChange, FileStatus}; @@ -72,21 +73,36 @@ pub struct FileView { } impl FileView { + /// `role` decides where each side's text comes from — the hunks (which rows are changes) are + /// already role-correct because `file` is that role's own [`FileChange`], but the surrounding + /// text must match the same two revisions the hunks were diffed against, or context lines + /// render one revision on one side and a different one on the other: + /// - old side: [`Role::Combined`]/[`Role::Staged`] read the `HEAD` blob; [`Role::Unstaged`] + /// reads the INDEX blob (unstaged is index ↔ worktree). + /// - new side: [`Role::Combined`]/[`Role::Unstaged`] read the worktree file; + /// [`Role::Staged`] reads the INDEX blob (staged is `HEAD` ↔ index). fn load( repo: &Repository, head_tree: &git2::Tree<'_>, file: &FileChange, + role: Role, ts: &mut TsHighlighter, ) -> Self { let old_source_path = file.old_path.as_deref().unwrap_or(file.path.as_str()); let old_text = match file.status { FileStatus::Added | FileStatus::Untracked => String::new(), - _ => read_head_blob(repo, head_tree, old_source_path), + _ => match role { + Role::Combined | Role::Staged => read_head_blob(repo, head_tree, old_source_path), + Role::Unstaged => read_index_blob(repo, old_source_path), + }, }; let new_text = match file.status { FileStatus::Deleted => String::new(), - _ => read_workdir_file(repo, &file.path), + _ => match role { + Role::Combined | Role::Unstaged => read_workdir_file(repo, &file.path), + Role::Staged => read_index_blob(repo, &file.path), + }, }; let old_lines: Vec = old_text.lines().map(str::to_string).collect(); @@ -241,6 +257,19 @@ fn read_head_blob(repo: &Repository, tree: &git2::Tree<'_>, path: &str) -> Strin .unwrap_or_default() } +/// Read the INDEX (staging area) copy of `path` as text — the "old" side of an unstaged +/// (index ↔ worktree) view and the "new" side of a staged (`HEAD` ↔ index) view. Reads stage-0 +/// (the ordinary, non-conflict entry); a path absent from the index (or with no stage-0 entry) +/// reads as empty, same graceful-default posture as [`read_head_blob`]. +fn read_index_blob(repo: &Repository, path: &str) -> String { + repo.index() + .ok() + .and_then(|index| index.get_path(Path::new(path), 0)) + .and_then(|entry| repo.find_blob(entry.id).ok()) + .map(|blob| String::from_utf8_lossy(blob.content()).into_owned()) + .unwrap_or_default() +} + fn read_workdir_file(repo: &Repository, path: &str) -> String { repo.workdir() .map(|wd| wd.join(path)) @@ -259,6 +288,138 @@ pub enum Layout { Inline, } +/// Which of the three per-file diff roles a [`FileView`] is built from. The **combined** role is +/// `HEAD` ↔ worktree (the whole change); **unstaged** is index ↔ worktree; **staged** is `HEAD` ↔ +/// index. A file need not have a change in every role — an untracked file has only an unstaged +/// change; a freshly `git add`ed one only a staged change; a partially-staged file has all three. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + Combined, + Unstaged, + Staged, +} + +/// The zoom the user *requested* via `z` — persists across file navigation (like [`Layout`]). The +/// actual state rendered per file is [`EffectiveZoom`], resolved by [`effective_zoom`] from this +/// plus the file's available sub-diffs; a file lacking the requested role collapses to +/// [`Role::Combined`] rather than showing an empty pane. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Zoom { + /// Unstaged pane stacked above staged pane, each independently navigable. The default — + /// the gate downgrades it to a single pane for files that don't have both sub-diffs, so the + /// common all-unstaged worktree still renders as one pane. + #[default] + Split, + Combined, + Unstaged, + Staged, +} + +/// The zoom actually rendered for a given file this frame — the gated resolution of a [`Zoom`] +/// against that file's available sub-diffs (see [`effective_zoom`]). Either a single pane over one +/// [`Role`], or the two-pane [`EffectiveZoom::Split`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectiveZoom { + Single(Role), + Split, +} + +/// Resolve the requested [`Zoom`] to the [`EffectiveZoom`] a file can actually show, given which of +/// its sub-diffs exist (`has_unstaged`/`has_staged` = the file's path appears in that role's +/// `DiffModel`) and whether it's stageable at all (`can_stage` = non-binary in M4). +/// +/// Rules (a pure gate, unit-tested against the full truth table): +/// - not stageable → [`Role::Combined`] (binary files render the placeholder; no attribution); +/// - `Combined` → `Combined`; +/// - `Unstaged` → `Unstaged` if it has one, else `Combined`; +/// - `Staged` → `Staged` if it has one, else `Combined`; +/// - `Split` → `Split` only if it has BOTH sub-diffs; else downgrade to whichever single sub-diff +/// exists; else `Combined`. +pub fn effective_zoom( + requested: Zoom, + has_unstaged: bool, + has_staged: bool, + can_stage: bool, +) -> EffectiveZoom { + if !can_stage { + return EffectiveZoom::Single(Role::Combined); + } + match requested { + Zoom::Combined => EffectiveZoom::Single(Role::Combined), + Zoom::Unstaged => { + if has_unstaged { + EffectiveZoom::Single(Role::Unstaged) + } else { + EffectiveZoom::Single(Role::Combined) + } + } + Zoom::Staged => { + if has_staged { + EffectiveZoom::Single(Role::Staged) + } else { + EffectiveZoom::Single(Role::Combined) + } + } + Zoom::Split => { + if has_unstaged && has_staged { + EffectiveZoom::Split + } else if has_unstaged { + EffectiveZoom::Single(Role::Unstaged) + } else if has_staged { + EffectiveZoom::Single(Role::Staged) + } else { + EffectiveZoom::Single(Role::Combined) + } + } + } +} + +/// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the +/// staged role. Focus decides which pane owns [`App::cursor`]/[`App::scroll`] and where the cursor +/// highlight draws; `w` toggles it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SplitPane { + Unstaged, + Staged, +} + +/// Cursor + derived scroll for a split's *unfocused* pane. The focused pane's equivalent state +/// lives directly on [`App`] (`cursor`/`scroll`) so every existing cursor-moving method keeps +/// operating on the focused pane unchanged; `w` swaps this in and out (see +/// [`App::toggle_split_focus`]). +#[derive(Debug, Clone, Copy, Default)] +struct PaneState { + cursor: usize, + scroll: usize, +} + +/// Slide `prev_scroll` the minimum amount to keep `cursor` within `[SCROLLOFF, pane_height - 1 - +/// SCROLLOFF]` of the viewport, then clamp to `[0, rows - pane_height]` (edge wins over margin). +/// The pure core of [`App::derive_scroll`], factored out so a split's unfocused pane can derive its +/// own scroll against ITS height without going through `App`'s focused-pane fields. +fn derive_scroll_value( + cursor: usize, + prev_scroll: usize, + rows: usize, + pane_height: usize, +) -> usize { + if rows == 0 { + return 0; + } + let pane_height = pane_height.max(1); + let cursor = cursor.min(rows - 1); + let bottom_margin = pane_height.saturating_sub(1).saturating_sub(SCROLLOFF); + + let mut scroll = prev_scroll; + if cursor < scroll + SCROLLOFF { + scroll = cursor.saturating_sub(SCROLLOFF); + } else if cursor > scroll + bottom_margin { + scroll = cursor.saturating_sub(bottom_margin); + } + let max_scroll = rows.saturating_sub(pane_height); + scroll.min(max_scroll) +} + /// Review session state: the combined diff's file list, per-file lazily loaded views, and /// navigation/scroll state. One long-lived [`TsHighlighter`] lives here (not per file) — its /// language-config cache is keyed per-instance, so a fresh highlighter per file would rebuild @@ -267,104 +428,328 @@ pub struct App { repo: Repository, /// The combined diff's files. git2 enumerates these in path order (verified in /// `tests`), so "current file index" is a stable alphabetical position, not an - /// arrival/discovery order that could reshuffle under the user. + /// arrival/discovery order that could reshuffle under the user. The file LIST stays + /// combined-driven even in split/zoom modes — only the rendered rows change per role. pub files: Vec, - views: Vec>, + /// The unstaged (index ↔ worktree) sub-diff, and, parallel to [`Self::files`], + /// [`Self::unstaged_idx`] mapping each combined file to its index here (or `None` when that + /// file has no unstaged change). The staged pair mirrors it. + unstaged_model: DiffModel, + staged_model: DiffModel, + unstaged_idx: Vec>, + staged_idx: Vec>, + /// Per-file, per-role lazily built views (parallel to [`Self::files`]). A slot stays `None` + /// until first access; a role slot ALSO stays `None` forever when that file has no change in + /// that role (see [`Self::ensure_role_loaded`]). + views_combined: Vec>, + views_unstaged: Vec>, + views_staged: Vec>, pub current: usize, /// Row index, in the ACTIVE layout's coordinate space, of the highlighted navigation - /// anchor — THE nav state (locked decision #2 in the M4 plan). `scroll` is derived from - /// this every time it moves, via [`Self::derive_scroll`]. + /// anchor — THE nav state (locked decision #2 in the M4 plan). In a split this is the + /// FOCUSED pane's cursor; the unfocused pane's lives in [`Self::alt`]. `scroll` is derived + /// from this every time it moves, via [`Self::derive_scroll`]. pub cursor: usize, - /// Top-of-viewport row index, in the active layout's space. Read directly by the renderer, - /// but never written except by [`Self::derive_scroll`] — every cursor-moving method ends by - /// calling it, so `scroll` always reflects the CURRENT `cursor`. + /// Top-of-viewport row index for the focused pane, in the active layout's space. Read + /// directly by the renderer, but never written except by [`Self::derive_scroll`] — every + /// cursor-moving method ends by calling it, so `scroll` always reflects the CURRENT `cursor`. pub scroll: usize, + /// Content height of the focused pane, written by the renderer each frame. In a single-pane + /// zoom this is the whole body; in a split it's the focused half (see [`Self::alt_height`]). pub pane_height: usize, + /// The unfocused split pane's cursor+scroll, swapped with the focused pane's on `w` (see + /// [`Self::toggle_split_focus`]). Meaningless outside [`EffectiveZoom::Split`]. + alt: PaneState, + /// Content height of the unfocused split pane, written by the renderer alongside + /// [`Self::pane_height`] — [`Self::derive_alt_scroll`] derives the unfocused pane's scroll + /// against THIS, not the focused pane's height. + pub(crate) alt_height: usize, /// Label for the old side of the diff, shown next to a rename's `old_path` in the header. - /// M3 only reviews the combined (`HEAD` ↔ worktree) diff, so this is always `"HEAD"` today; - /// M4's committed-changeset zoom will want to set this to the changeset's actual base rev. + /// M4 only reviews the uncommitted (`HEAD` ↔ worktree) diffs, so this is always `"HEAD"` + /// today; M5's committed-changeset zoom will want the changeset's actual base rev. pub base_label: String, highlighter: TsHighlighter, /// Current render layout; see [`Layout`]'s doc comment for the persistence contract. pub layout: Layout, + /// The requested zoom (cycled by `z`); the effective per-file zoom is resolved each frame via + /// [`effective_zoom`]. Persists across file navigation, like [`Self::layout`]. + pub zoom: Zoom, + /// 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, } impl App { - pub fn new(repo: Repository, combined: DiffModel) -> Self { - let n = combined.files.len(); + pub fn new(repo: Repository, diffs: WorktreeDiffs) -> Self { + let WorktreeDiffs { + staged, + unstaged, + combined, + } = diffs; + let files = combined.files; + let n = files.len(); + let unstaged_idx = files + .iter() + .map(|f| find_role_change(&unstaged, f)) + .collect(); + let staged_idx = files.iter().map(|f| find_role_change(&staged, f)).collect(); Self { repo, - files: combined.files, - views: (0..n).map(|_| None).collect(), + files, + unstaged_model: unstaged, + staged_model: staged, + unstaged_idx, + staged_idx, + views_combined: (0..n).map(|_| None).collect(), + views_unstaged: (0..n).map(|_| None).collect(), + views_staged: (0..n).map(|_| None).collect(), current: 0, cursor: 0, scroll: 0, pane_height: 20, + alt: PaneState::default(), + alt_height: 20, base_label: "HEAD".to_string(), highlighter: TsHighlighter::new(), layout: Layout::default(), + zoom: Zoom::default(), + split_focus: SplitPane::Unstaged, + } + } + + /// Resolve the [`EffectiveZoom`] for file `idx` this frame: the requested [`Self::zoom`] gated + /// against that file's available sub-diffs and stageability. Cheap (three lookups + the pure + /// [`effective_zoom`]) — re-evaluated per file per frame, no caching (locked decision #3). + pub(crate) fn effective_zoom_for(&self, idx: usize) -> EffectiveZoom { + let can_stage = self.files.get(idx).map(|f| !f.is_binary).unwrap_or(false); + let has_unstaged = self.unstaged_idx.get(idx).copied().flatten().is_some(); + let has_staged = self.staged_idx.get(idx).copied().flatten().is_some(); + effective_zoom(self.zoom, has_unstaged, has_staged, can_stage) + } + + /// The role whose view [`Self::cursor`]/[`Self::scroll`] currently drive for file `idx`: the + /// single effective role, or the focused split pane's role. + fn focused_role_for(&self, idx: usize) -> Role { + match self.effective_zoom_for(idx) { + EffectiveZoom::Single(role) => role, + EffectiveZoom::Split => self.split_focus_role(), + } + } + + /// The role of the currently focused split pane (or the pane that WOULD be focused). See + /// [`Self::split_focus`]. + pub(crate) fn split_focus_role(&self) -> Role { + match self.split_focus { + SplitPane::Unstaged => Role::Unstaged, + SplitPane::Staged => Role::Staged, } } - /// Load (and cache) the [`FileView`] for `idx`, unless the file is binary — binary files - /// skip content loading entirely (no blob read, no worktree read, no highlighting): there - /// is nothing for the SBS renderer to align, so [`crate::render`] shows a placeholder - /// without ever calling this. + fn unfocused_split_role(&self) -> Role { + match self.split_focus { + SplitPane::Unstaged => Role::Staged, + SplitPane::Staged => Role::Unstaged, + } + } + + fn views_for(&self, role: Role) -> &[Option] { + match role { + Role::Combined => &self.views_combined, + Role::Unstaged => &self.views_unstaged, + Role::Staged => &self.views_staged, + } + } + + fn views_for_mut(&mut self, role: Role) -> &mut [Option] { + match role { + Role::Combined => &mut self.views_combined, + Role::Unstaged => &mut self.views_unstaged, + Role::Staged => &mut self.views_staged, + } + } + + /// Read-only access to file `idx`'s already-loaded [`FileView`] for `role` (`None` if the role + /// has no change for the file, or it isn't loaded yet). + pub(crate) fn role_view_ref(&self, idx: usize, role: Role) -> Option<&FileView> { + self.views_for(role).get(idx).and_then(|v| v.as_ref()) + } + + /// Mutable access to file `idx`'s already-loaded [`FileView`] for `role` — does NOT trigger a + /// load (call [`Self::ensure_role_loaded`] first). Used by the renderer's word-span + /// cache-populating pass. + pub(crate) fn role_view_mut(&mut self, idx: usize, role: Role) -> Option<&mut FileView> { + self.views_for_mut(role) + .get_mut(idx) + .and_then(|v| v.as_mut()) + } + + /// Load (and cache) every [`FileView`] needed to render file `idx` under its effective zoom: + /// the single effective role, or BOTH split panes' roles. Binary files load nothing (the + /// renderer shows a placeholder). pub fn ensure_loaded(&mut self, idx: usize) { - let Some(file) = self.files.get(idx) else { - return; - }; - if file.is_binary { - return; + match self.effective_zoom_for(idx) { + EffectiveZoom::Single(role) => self.ensure_role_loaded(idx, role), + EffectiveZoom::Split => { + self.ensure_role_loaded(idx, Role::Unstaged); + self.ensure_role_loaded(idx, Role::Staged); + } } - if self.views[idx].is_none() { - // Re-peeled per call rather than cached on `App`: HEAD can move between file loads - // (a fine risk in M3's read-only TUI) and the tree is cheap to re-peel. - let Ok(head_tree) = self.repo.head().and_then(|h| h.peel_to_tree()) else { + } + + /// Load file `idx`'s [`FileView`] for one `role`, unless already loaded, binary, or the role + /// has no change for the file. The combined role builds from [`Self::files`]; the sub-roles + /// build from the matching [`FileChange`] in the unstaged/staged model. Each role's text is + /// sourced from the two revisions its hunks were diffed against (see [`FileView::load`]) so + /// context lines match on both sides. + fn ensure_role_loaded(&mut self, idx: usize, role: Role) { + let model_idx = match role { + Role::Combined => { + let Some(file) = self.files.get(idx) else { + return; + }; + if file.is_binary { + return; + } + if self.views_combined.get(idx).map(Option::is_some) != Some(false) { + return; + } + None + } + Role::Unstaged => self.unstaged_idx.get(idx).copied().flatten(), + Role::Staged => self.staged_idx.get(idx).copied().flatten(), + }; + + if role != Role::Combined { + let Some(mi) = model_idx else { + return; // no change in this role for this file + }; + if self.views_for(role).get(idx).map(Option::is_some) != Some(false) { + return; // already loaded (or slot absent) + } + let file = match role { + Role::Unstaged => &self.unstaged_model.files[mi], + Role::Staged => &self.staged_model.files[mi], + Role::Combined => unreachable!(), + }; + if file.is_binary { return; + } + // Build the view in a block so `head_tree` (which borrows `self.repo`) drops before + // the `views_for_mut` reborrow — same reason the combined path below can assign a + // direct field while `head_tree` is live but this method-call path cannot. + let view = { + // Re-peeled per call, same rationale as the combined path below. + let Ok(head_tree) = self.repo.head().and_then(|h| h.peel_to_tree()) else { + return; + }; + FileView::load(&self.repo, &head_tree, file, role, &mut self.highlighter) }; - let view = FileView::load( - &self.repo, - &head_tree, - &self.files[idx], - &mut self.highlighter, - ); - self.views[idx] = Some(view); + self.views_for_mut(role)[idx] = Some(view); + return; } + + // Combined role. + // Re-peeled per call rather than cached on `App`: HEAD can move between file loads and the + // tree is cheap to re-peel. + let Ok(head_tree) = self.repo.head().and_then(|h| h.peel_to_tree()) else { + return; + }; + let view = FileView::load( + &self.repo, + &head_tree, + &self.files[idx], + Role::Combined, + &mut self.highlighter, + ); + self.views_combined[idx] = Some(view); } pub fn current_view(&mut self) -> Option<&mut FileView> { - self.ensure_loaded(self.current); - self.views.get_mut(self.current).and_then(|v| v.as_mut()) + let role = self.focused_role_for(self.current); + self.ensure_role_loaded(self.current, role); + self.role_view_mut(self.current, role) } + /// The focused pane's [`FileView`] for the current file — the effective single role, or the + /// focused split pane's role. `None` if unloaded (binary, or a role with no change). pub fn current_view_ref(&self) -> Option<&FileView> { - self.views.get(self.current).and_then(|v| v.as_ref()) + self.role_view_ref(self.current, self.focused_role_for(self.current)) } - /// Jump the cursor to the current file's first hunk (or row 0, for a file with no hunks or - /// that isn't loaded yet), then re-derive `scroll`. Reads whichever of `FileView`'s two - /// first-hunk fields matches [`Self::layout`] — see [`FileView::first_inline_hunk_row`]'s - /// doc comment for why the SBS and inline positions are separate fields, not one translated - /// into the other. - pub fn jump_to_first_hunk(&mut self) { - let layout = self.layout; - self.cursor = self - .views - .get(self.current) - .and_then(|v| v.as_ref()) - .map(|v| match layout { + /// First-hunk row (in the active layout's space) of file `idx`'s `role` view, or 0 when the + /// view is absent/unloaded. Reads whichever of [`FileView`]'s two first-hunk fields matches + /// [`Self::layout`]. + fn role_first_hunk(&self, idx: usize, role: Role) -> usize { + self.role_view_ref(idx, role) + .map(|v| match self.layout { Layout::Sbs => v.first_hunk_row, Layout::Inline => v.first_inline_hunk_row, }) - .unwrap_or(0); + .unwrap_or(0) + } + + /// Reset BOTH panes to their role views' first hunks and refocus the top (unstaged) pane — + /// run on file open and zoom change. The two role coordinate spaces disagree, so carrying a + /// raw cursor index across a role/zoom switch would be meaningless; jumping to the role's own + /// first hunk (the same position a fresh file open lands on) is always valid and predictable. + fn reset_panes(&mut self) { + self.split_focus = SplitPane::Unstaged; + self.alt = PaneState::default(); + match self.effective_zoom_for(self.current) { + EffectiveZoom::Single(role) => { + self.cursor = self.role_first_hunk(self.current, role); + } + EffectiveZoom::Split => { + self.cursor = self.role_first_hunk(self.current, Role::Unstaged); + self.alt.cursor = self.role_first_hunk(self.current, Role::Staged); + } + } self.derive_scroll(); + // The unfocused pane's scroll is derived at render time, once its height is known. } - /// Load the current file (if not binary) and jump the cursor to its first hunk. + /// Jump the focused pane's cursor to its role view's first hunk, then re-derive `scroll`. + pub fn jump_to_first_hunk(&mut self) { + let role = self.focused_role_for(self.current); + self.cursor = self.role_first_hunk(self.current, role); + self.derive_scroll(); + } + + /// Load the current file's needed views and reset both panes to their first hunks. pub fn open_current(&mut self) { self.ensure_loaded(self.current); - self.jump_to_first_hunk(); + self.reset_panes(); + } + + /// 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) { + self.zoom = match self.zoom { + Zoom::Split => Zoom::Combined, + Zoom::Combined => Zoom::Unstaged, + Zoom::Unstaged => Zoom::Staged, + Zoom::Staged => Zoom::Split, + }; + self.open_current(); + } + + /// Swap focus between the two split panes (`w`) — swaps `cursor`/`scroll`/`pane_height` with + /// the stashed unfocused pane so the existing cursor methods keep driving the focused pane, and + /// re-derives the newly focused pane's scroll against its own (just-swapped-in) height. A no-op + /// outside a split. + pub fn toggle_split_focus(&mut self) { + if self.effective_zoom_for(self.current) != EffectiveZoom::Split { + return; + } + std::mem::swap(&mut self.cursor, &mut self.alt.cursor); + std::mem::swap(&mut self.scroll, &mut self.alt.scroll); + std::mem::swap(&mut self.pane_height, &mut self.alt_height); + self.split_focus = match self.split_focus { + SplitPane::Unstaged => SplitPane::Staged, + SplitPane::Staged => SplitPane::Unstaged, + }; + self.derive_scroll(); } pub fn next_file(&mut self) { @@ -383,8 +768,9 @@ impl App { self.open_current(); } - fn row_count(&self) -> usize { - self.current_view_ref() + /// Row count of file `idx`'s `role` view in the active layout's space (0 if absent/unloaded). + fn role_row_count(&self, idx: usize, role: Role) -> usize { + self.role_view_ref(idx, role) .map(|v| match self.layout { Layout::Sbs => v.display.len(), Layout::Inline => v.inline.len(), @@ -392,6 +778,14 @@ impl App { .unwrap_or(0) } + fn row_count(&self) -> usize { + self.role_row_count(self.current, self.focused_role_for(self.current)) + } + + /// Max `scroll` value keeping the last row reachable — the focused pane's row count minus its + /// height. Now that scroll derivation lives in [`derive_scroll_value`], this survives only as a + /// bound the scroll tests assert against. + #[cfg(test)] fn max_scroll(&self) -> usize { self.row_count().saturating_sub(self.pane_height.max(1)) } @@ -408,30 +802,41 @@ impl App { }; } - /// Re-derive `scroll` from `cursor` so the cursor stays visible: it keeps `cursor` within - /// `[scroll + SCROLLOFF, scroll + pane_height - 1 - SCROLLOFF]` by sliding `scroll` the - /// MINIMUM amount needed (never re-centering) — the familiar vim `scrolloff` behavior. Near - /// a file edge, where honoring both margins at once isn't possible, the edge wins: the final - /// clamp to `[0, max_scroll()]` lets `cursor` reach row 0 or the last row even though the - /// margin can't be kept there. Every cursor-moving method ends by calling this — `scroll` is - /// otherwise never written directly. - fn derive_scroll(&mut self) { + /// Re-derive the FOCUSED pane's `scroll` from its `cursor` so the cursor stays visible. See + /// [`derive_scroll_value`] for the margin/edge behavior; every cursor-moving method ends by + /// calling this — `scroll` is otherwise never written directly. `pub(crate)` so the split + /// renderer can re-derive the focused pane's scroll once it knows the (render-time-only) pane + /// height. + pub(crate) fn derive_scroll(&mut self) { let rows = self.row_count(); - if rows == 0 { - self.scroll = 0; - return; - } - let pane_height = self.pane_height.max(1); - let cursor = self.cursor.min(rows - 1); - let bottom_margin = pane_height.saturating_sub(1).saturating_sub(SCROLLOFF); - - let mut scroll = self.scroll; - if cursor < scroll + SCROLLOFF { - scroll = cursor.saturating_sub(SCROLLOFF); - } else if cursor > scroll + bottom_margin { - scroll = cursor.saturating_sub(bottom_margin); + self.scroll = derive_scroll_value(self.cursor, self.scroll, rows, self.pane_height); + } + + /// Re-derive the UNFOCUSED split pane's scroll against its own cursor, row count, and + /// [`Self::alt_height`] — called by the renderer each split frame, after the pane heights are + /// known. + pub(crate) fn derive_alt_scroll(&mut self) { + let role = self.unfocused_split_role(); + let rows = self.role_row_count(self.current, role); + self.alt.scroll = + derive_scroll_value(self.alt.cursor, self.alt.scroll, rows, self.alt_height); + } + + /// 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 + /// (single) state. + pub(crate) fn pane_render_state(&self, role: Role) -> (usize, Option) { + let pane = match role { + Role::Unstaged => SplitPane::Unstaged, + Role::Staged => SplitPane::Staged, + Role::Combined => return (self.scroll, Some(self.cursor)), + }; + if self.split_focus == pane { + (self.scroll, Some(self.cursor)) + } else { + (self.alt.scroll, None) } - self.scroll = scroll.min(self.max_scroll()); } /// Move the cursor by `delta` rows, clamped to `[0, row_count() - 1]` (a no-op on an empty @@ -513,10 +918,39 @@ impl App { Layout::Inline => Layout::Sbs, }; self.clamp_cursor(); + // In a split the flip is global (both panes reflow), so the unfocused pane's cursor needs + // the same clamp against ITS role's new-layout row count. Its scroll is re-derived at + // render time. + 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(); } } +/// Index of the [`FileChange`] in a role's [`DiffModel`] that corresponds to combined `file`, or +/// `None` when the role has no change for it (e.g. an untracked file in the staged model). +/// +/// Matches by `path` (the common case), with rename-aware fallbacks: the combined and sub-diffs +/// agree on a rename's new `path`, but a file renamed in only one role can leave the match to +/// `old_path` on either side. Path equality wins for the overwhelming majority; the fallbacks just +/// avoid dropping the odd asymmetric-rename pairing. +fn find_role_change(model: &DiffModel, file: &FileChange) -> Option { + model.files.iter().position(|m| { + m.path == file.path + || (m.old_path.is_some() && m.old_path == file.old_path) + || m.old_path.as_deref() == Some(file.path.as_str()) + || (file.old_path.as_deref().is_some() + && file.old_path.as_deref() == Some(m.path.as_str())) + }) +} + /// True for a display row that carries change content (Del/Add/Filler on either side) rather /// than pure context — the unit hunk navigation jumps between. fn is_hunk_content_row(row: &DisplayRow) -> bool { @@ -580,10 +1014,10 @@ pub(crate) mod test_support { pub(crate) fn app_from_fixture(fixture: &Fixture) -> App { let repo = fixture.repo().expect("fixture repo"); - let combined = diff_uncommitted(repo).expect("diff_uncommitted").combined; + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); let owned = Repository::open(repo.workdir().expect("fixture has a workdir")) .expect("reopen fixture repo"); - App::new(owned, combined) + App::new(owned, diffs) } } @@ -1120,4 +1554,253 @@ mod tests { "scroll must be clamped to the new layout's max_scroll" ); } + + // ---- M4 zoom: gate, cycling, and split per-pane state ---------------------------------- + + /// A file with three genuinely distinct HEAD / index / worktree states — so it has BOTH a + /// staged sub-diff (HEAD ↔ index) and an unstaged one (index ↔ worktree), the precondition + /// for the split. The changed tokens on each side (`BETAEDIT` staged, `GAMMAEDIT` unstaged) + /// don't collide with the `UNSTAGED`/`STAGED` captions. + fn partially_staged_fixture() -> 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() + } + + #[test] + fn effective_zoom_gate_truth_table() { + use super::effective_zoom; + use super::EffectiveZoom::{Single, Split}; + use super::Role::{Combined, Staged, Unstaged}; + use super::Zoom; + + // Not stageable (binary) collapses to Combined regardless of the requested zoom or which + // sub-diffs exist. + for req in [Zoom::Split, Zoom::Combined, Zoom::Unstaged, Zoom::Staged] { + for hu in [false, true] { + for hs in [false, true] { + assert_eq!( + effective_zoom(req, hu, hs, false), + Single(Combined), + "req={req:?} hu={hu} hs={hs} can_stage=false" + ); + } + } + } + + // Combined requested: always Combined. + for hu in [false, true] { + for hs in [false, true] { + assert_eq!( + effective_zoom(Zoom::Combined, hu, hs, true), + Single(Combined) + ); + } + } + + // Unstaged requested: its sub-diff if present, else Combined. + assert_eq!( + effective_zoom(Zoom::Unstaged, true, false, true), + Single(Unstaged) + ); + assert_eq!( + effective_zoom(Zoom::Unstaged, true, true, true), + Single(Unstaged) + ); + assert_eq!( + effective_zoom(Zoom::Unstaged, false, true, true), + Single(Combined) + ); + assert_eq!( + effective_zoom(Zoom::Unstaged, false, false, true), + Single(Combined) + ); + + // Staged requested: its sub-diff if present, else Combined. + assert_eq!( + effective_zoom(Zoom::Staged, false, true, true), + Single(Staged) + ); + assert_eq!( + effective_zoom(Zoom::Staged, true, true, true), + Single(Staged) + ); + assert_eq!( + effective_zoom(Zoom::Staged, true, false, true), + Single(Combined) + ); + assert_eq!( + effective_zoom(Zoom::Staged, false, false, true), + Single(Combined) + ); + + // Split requested: Split only with BOTH; else downgrade to the single sub-diff; else + // Combined. + assert_eq!(effective_zoom(Zoom::Split, true, true, true), Split); + assert_eq!( + effective_zoom(Zoom::Split, true, false, true), + Single(Unstaged) + ); + assert_eq!( + effective_zoom(Zoom::Split, false, true, true), + Single(Staged) + ); + assert_eq!( + effective_zoom(Zoom::Split, false, false, true), + Single(Combined) + ); + } + + #[test] + fn partially_staged_file_resolves_to_split_by_default() { + use super::EffectiveZoom; + + let fixture = partially_staged_fixture(); + let app = app_from_fixture(&fixture); + assert_eq!(app.zoom, super::Zoom::Split, "default zoom is split"); + assert_eq!( + app.effective_zoom_for(0), + EffectiveZoom::Split, + "a file with both a staged and an unstaged sub-diff renders as a split" + ); + } + + #[test] + fn sub_view_context_text_reads_the_index_not_the_worktree() { + use super::Role; + + // HEAD: alpha/beta/gamma; index: alpha/BETAEDIT/gamma; worktree: alpha/BETAEDIT/GAMMAEDIT. + // The staged view (HEAD ↔ index) diffs beta→BETAEDIT and leaves gamma as a CONTEXT line — + // whose new side is the index copy "gamma", NOT the worktree's "GAMMAEDIT". Symmetrically, + // the unstaged view (index ↔ worktree) diffs gamma→GAMMAEDIT and leaves BETAEDIT context, + // whose old side is the index copy "BETAEDIT", NOT HEAD's "beta". Before per-role text + // sourcing, both sub-views read old=HEAD/new=worktree, so these context lines showed one + // revision on one side and a different one on the other. + let fixture = partially_staged_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); // loads both split roles + + let staged = app + .role_view_ref(0, Role::Staged) + .expect("staged sub-view loaded"); + assert!( + staged.new_text().contains("gamma") && !staged.new_text().contains("GAMMAEDIT"), + "staged pane's new side must be the INDEX copy (gamma), not the worktree \ + (GAMMAEDIT); got: {:?}", + staged.new_text() + ); + + let unstaged = app + .role_view_ref(0, Role::Unstaged) + .expect("unstaged sub-view loaded"); + assert!( + unstaged.old_text().contains("BETAEDIT"), + "unstaged pane's old side must be the INDEX copy (BETAEDIT), not HEAD (beta); \ + got: {:?}", + unstaged.old_text() + ); + } + + #[test] + fn cycle_zoom_walks_the_four_states_and_persists_across_file_nav() { + use super::Zoom; + + 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", + ) + .untracked_file("z_other.txt", "hello\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + assert_eq!(app.zoom, Zoom::Split, "default"); + app.cycle_zoom(); + assert_eq!(app.zoom, Zoom::Combined); + app.cycle_zoom(); + assert_eq!(app.zoom, Zoom::Unstaged); + app.cycle_zoom(); + assert_eq!(app.zoom, Zoom::Staged); + app.cycle_zoom(); + assert_eq!(app.zoom, Zoom::Split, "cycles back to split"); + + // Persists across file navigation, like layout. + app.cycle_zoom(); // -> Combined + assert_eq!(app.zoom, Zoom::Combined); + app.next_file(); + assert_eq!( + app.zoom, + Zoom::Combined, + "zoom must persist across next_file" + ); + app.prev_file(); + assert_eq!(app.zoom, Zoom::Combined, "and across prev_file"); + } + + #[test] + fn split_panes_keep_independent_cursors_and_swap_on_focus_toggle() { + use super::SplitPane; + + let fixture = partially_staged_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + // The top (unstaged) pane is focused; each pane opened at its own role's first hunk. + assert_eq!(app.split_focus, SplitPane::Unstaged); + let staged_cursor = app.alt.cursor; + + // Moving the focused (unstaged) cursor must not disturb the stashed staged pane. + app.scroll_top(); + assert_eq!(app.cursor, 0, "focused pane cursor moved to the top"); + assert_eq!( + app.alt.cursor, staged_cursor, + "the unfocused pane's cursor is independent of focused-pane moves" + ); + + // `w` swaps the two panes' state and flips focus to the bottom (staged) pane. + app.toggle_split_focus(); + assert_eq!(app.split_focus, SplitPane::Staged); + assert_eq!( + app.cursor, staged_cursor, + "focus swap brings the staged pane's own cursor into the focused slot" + ); + assert_eq!( + app.alt.cursor, 0, + "the moved unstaged cursor is stashed as the now-unfocused pane" + ); + } + + #[test] + fn toggle_split_focus_is_a_noop_outside_a_split() { + use super::SplitPane; + + // An untracked file has only an unstaged sub-diff, so the default split downgrades to a + // single unstaged pane — there is no second pane to focus. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("only.txt", "one\ntwo\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + assert_eq!(app.split_focus, SplitPane::Unstaged); + app.toggle_split_focus(); + assert_eq!( + app.split_focus, + SplitPane::Unstaged, + "focus toggle must do nothing when the file isn't a split" + ); + } } diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 5dd2852..a021c6d 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -15,16 +15,17 @@ fn main() -> Result<()> { Cli::parse(); let repo = Repository::discover(".").into_diagnostic()?; - let combined = diff_uncommitted(&repo).into_diagnostic()?.combined; + let diffs = diff_uncommitted(&repo).into_diagnostic()?; - if combined.files.is_empty() { + if diffs.combined.files.is_empty() { eprintln!("nothing to review"); return Ok(()); } // `App` owns its own `Repository` handle (see `app.rs`'s doc comment) — moved in here after - // `diff_uncommitted` is done borrowing it. - let mut app = App::new(repo, combined); + // `diff_uncommitted` is done borrowing it. The whole `WorktreeDiffs` goes in: the file list is + // combined-driven, but the per-role zoom panes need the staged/unstaged sub-diffs too. + let mut app = App::new(repo, diffs); app.open_current(); tui::run(&mut app).into_diagnostic()?; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index ac48147..4686612 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -13,7 +13,7 @@ use ratatui::widgets::Paragraph; use ratatui::Frame; use crate::align::{CellKind, DisplayRow, InlineRow, Row}; -use crate::app::{App, FileView, Layout as AppLayout}; +use crate::app::{App, EffectiveZoom, FileView, Layout as AppLayout, Role}; use crate::highlight::FgSpan; use crate::model::FileStatus; use crate::wordiff::Span as WordSpan; @@ -266,8 +266,7 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { } fn render_footer(frame: &mut Frame, area: Rect) { - let text = - "j/k scroll Ctrl-d/u half-page g/G top/bottom ]f/[f file ]h/[h hunk L layout q quit"; + let text = "j/k scroll ]f/[f file ]h/[h hunk L layout z zoom w focus q quit"; frame.render_widget( Paragraph::new(text).style(Style::default().fg(FG_DIM)), area, @@ -302,15 +301,135 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect) { } app.ensure_loaded(idx); - app.pane_height = area.height as usize; + // The gate re-evaluates the effective zoom for the current file every frame (no caching — + // ratatui relayout is free, per locked decision #3). + match app.effective_zoom_for(idx) { + EffectiveZoom::Single(role) => { + app.pane_height = area.height as usize; + let scroll = app.scroll; + let cursor = Some(app.cursor); + match app.layout { + AppLayout::Sbs => render_pane_sbs(frame, app, area, idx, role, scroll, cursor), + AppLayout::Inline => { + render_pane_inline(frame, app, area, idx, role, scroll, cursor) + } + } + } + EffectiveZoom::Split => render_body_split(frame, app, area, idx), + } +} + +/// Render the two-pane split: unstaged pane on top, staged on the bottom, each with a dim role +/// caption, each rendering its role view in the current [`AppLayout`] with its OWN cursor+scroll — +/// the cursor highlight draws only in the focused pane. The body area splits caption(1) + +/// 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) { + // 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 { + let role = app.split_focus_role(); + app.pane_height = area.height as usize; + let (scroll, cursor) = app.pane_render_state(role); + match app.layout { + AppLayout::Sbs => render_pane_sbs(frame, app, area, idx, role, scroll, cursor), + AppLayout::Inline => render_pane_inline(frame, app, area, idx, role, scroll, cursor), + } + return; + } + + let content_total = area.height - 2; + let top_h = content_total / 2; + let bot_h = content_total - top_h; + + let unstaged_caption = Rect::new(area.x, area.y, area.width, 1); + let unstaged_content = Rect::new(area.x, area.y + 1, area.width, top_h); + let staged_caption = Rect::new(area.x, area.y + 1 + top_h, area.width, 1); + let staged_content = Rect::new(area.x, area.y + 2 + top_h, area.width, bot_h); + + // The focused pane owns `pane_height`; the other, `alt_height`. Both scrolls are derived here, + // once the (render-time-only) heights are known. + let (focused_h, unfocused_h) = if app.split_focus_role() == Role::Unstaged { + (top_h, bot_h) + } else { + (bot_h, top_h) + }; + app.pane_height = focused_h as usize; + app.alt_height = unfocused_h as usize; + app.derive_scroll(); + app.derive_alt_scroll(); + + render_caption(frame.buffer_mut(), unstaged_caption, "UNSTAGED"); + render_caption(frame.buffer_mut(), staged_caption, "STAGED"); + + let (u_scroll, u_cursor) = app.pane_render_state(Role::Unstaged); + let (s_scroll, s_cursor) = app.pane_render_state(Role::Staged); match app.layout { - AppLayout::Sbs => render_body_sbs(frame, app, area), - AppLayout::Inline => render_body_inline(frame, app, area), + AppLayout::Sbs => { + render_pane_sbs( + frame, + app, + unstaged_content, + idx, + Role::Unstaged, + u_scroll, + u_cursor, + ); + render_pane_sbs( + frame, + app, + staged_content, + idx, + Role::Staged, + s_scroll, + s_cursor, + ); + } + AppLayout::Inline => { + render_pane_inline( + frame, + app, + unstaged_content, + idx, + Role::Unstaged, + u_scroll, + u_cursor, + ); + render_pane_inline( + frame, + app, + staged_content, + idx, + Role::Staged, + s_scroll, + s_cursor, + ); + } } } -fn render_body_sbs(frame: &mut Frame, app: &mut App, area: Rect) { +/// 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) { + let text = format!("── {label} ──"); + let line = Line::from(TSpan::styled(text, Style::default().fg(FG_DIM))); + 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`. +#[allow(clippy::too_many_arguments)] +fn render_pane_sbs( + frame: &mut Frame, + app: &mut App, + area: Rect, + idx: usize, + role: Role, + scroll: usize, + cursor: Option, +) { let left_w = area.width.saturating_sub(1) / 2; let right_w = area.width.saturating_sub(1).saturating_sub(left_w); let hlayout = Layout::default() @@ -325,15 +444,13 @@ fn render_body_sbs(frame: &mut Frame, app: &mut App, area: Rect) { let div_area = hlayout[1]; let new_area = hlayout[2]; - let Some(view) = app.current_view_ref() else { + let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), old_area); return; }; let old_gutter_w = gutter_width(view.old_line_count()); let new_gutter_w = gutter_width(view.new_line_count()); - let scroll = app.scroll; - let pane_height = app.pane_height; - let end = (scroll + pane_height).min(view.display.len()); + let end = (scroll + area.height as usize).min(view.display.len()); // Phase 1 (mutable): populate the word-span cache for visible paired rows. Phase 2 below // re-borrows `app`/`view` immutably to build lines — kept as the same two-phase dance the @@ -342,7 +459,7 @@ fn render_body_sbs(frame: &mut Frame, app: &mut App, area: Rect) { // borrow checker requires the cache-populating borrow to end before the line-building // borrow begins; there's no runtime benefit to trading that compile-time proof for // `RefCell` interior mutability here. - if let Some(view) = app.current_view() { + if let Some(view) = app.role_view_mut(idx, role) { for row_idx in scroll..end { if matches!(view.display.get(row_idx), Some(DisplayRow::Row(r)) if r.is_word_diff_pair()) { @@ -351,7 +468,7 @@ fn render_body_sbs(frame: &mut Frame, app: &mut App, area: Rect) { } } - let Some(view) = app.current_view_ref() else { + let Some(view) = app.role_view_ref(idx, role) else { return; }; @@ -363,7 +480,7 @@ fn render_body_sbs(frame: &mut Frame, app: &mut App, area: Rect) { for (i, row_idx) in (scroll..end).enumerate() { let y = area.y + i as u16; - let is_cursor = row_idx == app.cursor; + let is_cursor = cursor == Some(row_idx); match &view.display[row_idx] { DisplayRow::Gap { skipped } => { render_gap_row(frame.buffer_mut(), area, y, *skipped, is_cursor); @@ -498,20 +615,29 @@ fn build_inline_line( Line::from(spans) } -fn render_body_inline(frame: &mut Frame, app: &mut App, area: Rect) { - let Some(view) = app.current_view_ref() else { +/// Render one inline pane of `role`'s view for file `idx` into `area`, scrolled to `scroll`. See +/// [`render_pane_sbs`] for the `cursor`/highlight contract; this is its inline-coordinate-space +/// analog. +fn render_pane_inline( + frame: &mut Frame, + app: &mut App, + area: Rect, + idx: usize, + role: Role, + scroll: usize, + cursor: Option, +) { + let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), area); return; }; let old_gutter_w = gutter_width(view.old_line_count()); let new_gutter_w = gutter_width(view.new_line_count()); - let scroll = app.scroll; - let pane_height = app.pane_height; - let end = (scroll + pane_height).min(view.inline.len()); + let end = (scroll + area.height as usize).min(view.inline.len()); - // Same two-phase mutable/immutable dance as `render_body_sbs`, over the inline coordinate + // Same two-phase mutable/immutable dance as `render_pane_sbs`, over the inline coordinate // space instead. - if let Some(view) = app.current_view() { + if let Some(view) = app.role_view_mut(idx, role) { for row_idx in scroll..end { if matches!(view.inline.get(row_idx), Some(r) if r.is_word_diff_pair()) { view.inline_word_spans_for_row(row_idx); @@ -519,13 +645,13 @@ fn render_body_inline(frame: &mut Frame, app: &mut App, area: Rect) { } } - let Some(view) = app.current_view_ref() else { + let Some(view) = app.role_view_ref(idx, role) else { return; }; for (i, row_idx) in (scroll..end).enumerate() { let y = area.y + i as u16; - let is_cursor = row_idx == app.cursor; + let is_cursor = cursor == Some(row_idx); match &view.inline[row_idx] { InlineRow::Gap { skipped } => { render_gap_row(frame.buffer_mut(), area, y, *skipped, is_cursor); @@ -922,4 +1048,92 @@ mod tests { own row" ); } + + #[test] + fn split_renders_both_role_captions_stacked_with_content_in_each_pane() { + // A partially-staged file has both a staged (HEAD ↔ index) and an unstaged (index ↔ + // worktree) sub-diff, so the default split renders two stacked panes. + 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); + + let unstaged_cap = content + .iter() + .position(|line| line.contains("UNSTAGED")) + .expect("unstaged caption present"); + let staged_cap = content + .iter() + .position(|line| line.contains("STAGED") && !line.contains("UNSTAGED")) + .expect("staged caption present"); + assert!( + unstaged_cap < staged_cap, + "the unstaged pane's caption must sit above the staged pane's, got:\n{}", + content.join("\n") + ); + + // Both panes actually render their file (the shared context line `alpha` shows up once + // per pane) — one below each caption. + assert!( + content[unstaged_cap + 1..staged_cap] + .iter() + .any(|line| line.contains("alpha")), + "expected file content under the unstaged caption, got:\n{}", + content.join("\n") + ); + assert!( + content[staged_cap + 1..] + .iter() + .any(|line| line.contains("alpha")), + "expected file content under the staged caption, got:\n{}", + content.join("\n") + ); + } + + #[test] + fn single_pane_zoom_is_identical_to_combined_for_an_unstaged_only_file() { + // The common case: a dirty-but-unstaged file. The default split gate downgrades it to a + // single unstaged pane, whose view is byte-for-byte the combined view (index == HEAD when + // nothing is staged) — so a user who never presses `z` sees exactly the pre-zoom app. + let old = "l1\nl2\nl3\nl4\nl5\nold word here\nl7\nl8\nl9\nl10\n"; + let new = "l1\nl2\nl3\nl4\nl5\nnew word here\nl7\nl8\nl9\nl10\n"; + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("small.txt", old, new) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + let default_buf = render_once(&mut app, 60, 20); + + // No split chrome leaks into the single-pane render. + for line in buf_lines(&default_buf) { + assert!( + !line.contains("UNSTAGED") && !line.contains("STAGED"), + "single-pane render must not show a split caption, got line: {line:?}" + ); + } + + // Explicitly zoom to Combined and re-render — must be pixel-identical. + app.cycle_zoom(); + assert_eq!(app.zoom, crate::app::Zoom::Combined); + let combined_buf = render_once(&mut app, 60, 20); + assert_eq!( + default_buf, combined_buf, + "the default (downgraded-to-unstaged) render must match the combined-zoom render \ + cell-for-cell for an unstaged-only file" + ); + } } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 9638e69..ecd2910 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -58,6 +58,8 @@ enum Action { NextHunk, PrevHunk, ToggleLayout, + CycleZoom, + ToggleSplitFocus, None, } @@ -88,6 +90,8 @@ fn map_key(pending: &mut Option, key: KeyEvent, pane_height: usize) -> Act KeyCode::Char('g') => Action::ScrollTop, KeyCode::Char('G') => Action::ScrollBottom, KeyCode::Char('L') => Action::ToggleLayout, + KeyCode::Char('z') => Action::CycleZoom, + KeyCode::Char('w') => Action::ToggleSplitFocus, KeyCode::Tab => Action::NextFile, KeyCode::BackTab => Action::PrevFile, KeyCode::Char(']') => { @@ -114,6 +118,8 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::NextHunk => app.next_hunk_row(), Action::PrevHunk => app.prev_hunk_row(), Action::ToggleLayout => app.toggle_layout(), + Action::CycleZoom => app.cycle_zoom(), + Action::ToggleSplitFocus => app.toggle_split_focus(), Action::None => {} } false @@ -261,6 +267,19 @@ mod tests { ); } + #[test] + fn z_and_w_map_to_zoom_and_split_focus() { + let mut pending = None; + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('z')), 20), + Action::CycleZoom + ); + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('w')), 20), + Action::ToggleSplitFocus + ); + } + #[test] fn tab_and_backtab_map_to_file_nav() { let mut pending = None; From cc525197498c8565d6720ac32834a8ff38a316c5 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 00:39:46 -0400 Subject: [PATCH 030/203] feat(review): color combined view by staged-ness attribution --- git-workon-review/src/app.rs | 23 +++ git-workon-review/src/attribute.rs | 203 ++++++++++++++++++++++++++ git-workon-review/src/lib.rs | 1 + git-workon-review/src/render.rs | 220 +++++++++++++++++++++++++++-- 4 files changed, 435 insertions(+), 12 deletions(-) create mode 100644 git-workon-review/src/attribute.rs diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index ff8caa4..cd180a5 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -536,6 +536,29 @@ impl App { } } + /// The sub-[`FileChange`] backing file `idx`'s `role` view: `self.files[idx]` itself for + /// [`Role::Combined`], or the matching entry in the unstaged/staged model (`None` if that + /// role has no change for this file). Used by the renderer to build a fresh + /// [`crate::attribute::Attribution`] for the combined role each frame — see that module's + /// docs for why the two sub-roles' hunks (not the combined ones) are the attribution source. + pub(crate) fn role_change(&self, idx: usize, role: Role) -> Option<&FileChange> { + match role { + Role::Combined => self.files.get(idx), + Role::Unstaged => self + .unstaged_idx + .get(idx) + .copied() + .flatten() + .map(|mi| &self.unstaged_model.files[mi]), + Role::Staged => self + .staged_idx + .get(idx) + .copied() + .flatten() + .map(|mi| &self.staged_model.files[mi]), + } + } + /// The role of the currently focused split pane (or the pane that WOULD be focused). See /// [`Self::split_focus`]. pub(crate) fn split_focus_role(&self) -> Role { diff --git a/git-workon-review/src/attribute.rs b/git-workon-review/src/attribute.rs new file mode 100644 index 0000000..9e76c02 --- /dev/null +++ b/git-workon-review/src/attribute.rs @@ -0,0 +1,203 @@ +//! Staged-ness attribution for the **combined** (`HEAD` ↔ worktree) view — locked decision #7 in +//! the M4 plan. Pure: given a file's unstaged/staged sub-[`FileChange`]s (already looked up by +//! `App` via its `unstaged_idx`/`staged_idx` mapping), produces two membership sets keyed by the +//! exact line numbers the combined view's `AlignedRow`s already carry. No content matching, no +//! reconstruction — our rows carry real `old_lnum`/`new_lnum`, unlike the +//! `review-tui-spike` prototype's renderer, which had to reconstruct them and so needed a +//! del-run anchor heuristic to stay in sync with its picker. That heuristic has no analog here. +//! +//! ## The asymmetry (forced by coordinate alignment, not a stylistic choice) +//! +//! The combined view's OLD side is `HEAD` — the same "old" reference as the **staged** diff +//! (`HEAD` ↔ index). So a combined **deletion** at `old_lnum` M is "already staged" exactly when +//! the staged diff also deletes `old_lnum` M. +//! +//! The combined view's NEW side is the worktree — the same "new" reference as the **unstaged** +//! diff (index ↔ worktree). So a combined **addition** at `new_lnum` N is "not yet staged" +//! exactly when the unstaged diff also adds `new_lnum` N. +//! +//! These are two different sub-diffs keyed on two different sides — do not "fix" this to be +//! symmetric; the asymmetry is what makes the lookup correct. + +use std::collections::HashSet; + +use crate::model::{FileChange, LineKind}; + +/// Per-file membership sets built fresh each frame (see `render`'s combined-role render path) — +/// never cached on `App`, since the index can move out from under a stale cache (the M4 index +/// watcher refreshes the index independently of the render loop). +#[derive(Debug, Clone, Default)] +pub struct Attribution { + /// `new_lnum`s of every addition in the file's UNSTAGED (index ↔ worktree) sub-diff. An + /// combined Add cell at one of these lines is NOT YET staged (renders bright). + pub unstaged_adds: HashSet, + /// `old_lnum`s of every deletion in the file's STAGED (`HEAD` ↔ index) sub-diff. A combined + /// Del cell at one of these lines IS already staged (renders dim). + pub staged_dels: HashSet, +} + +impl Attribution { + /// Build from the current file's unstaged/staged sub-`FileChange`s, either of which may be + /// absent (the file has no change in that role) — an absent role contributes an empty set on + /// its axis rather than an error, so a file with no staged sub-diff renders every Del bright, + /// and a file with no unstaged sub-diff renders every Add dim. + pub fn build(unstaged: Option<&FileChange>, staged: Option<&FileChange>) -> Self { + let mut unstaged_adds = HashSet::new(); + if let Some(file) = unstaged { + for hunk in &file.hunks { + for line in &hunk.lines { + if line.kind == LineKind::Addition { + if let Some(n) = line.new_lnum { + unstaged_adds.insert(n); + } + } + } + } + } + + let mut staged_dels = HashSet::new(); + if let Some(file) = staged { + for hunk in &file.hunks { + for line in &hunk.lines { + if line.kind == LineKind::Deletion { + if let Some(n) = line.old_lnum { + staged_dels.insert(n); + } + } + } + } + } + + Self { + unstaged_adds, + staged_dels, + } + } + + /// True when a combined Add cell at `new_lnum` is NOT YET staged (renders bright). + pub fn add_is_unstaged(&self, new_lnum: u32) -> bool { + self.unstaged_adds.contains(&new_lnum) + } + + /// True when a combined Del cell at `old_lnum` IS already staged (renders dim). + pub fn del_is_staged(&self, old_lnum: u32) -> bool { + self.staged_dels.contains(&old_lnum) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Hunk, HunkLine}; + + fn line(kind: LineKind, old: Option, new: Option) -> HunkLine { + HunkLine { + kind, + content: Vec::new(), + old_lnum: old, + new_lnum: new, + missing_newline: false, + } + } + + fn file_with_hunks(hunks: Vec) -> FileChange { + FileChange { + path: "f.txt".to_string(), + old_path: None, + status: crate::model::FileStatus::Modified, + is_binary: false, + old_mode: 0o100644, + new_mode: 0o100644, + hunks, + } + } + + fn hunk(lines: Vec) -> Hunk { + Hunk { + old_start: 1, + old_count: 1, + new_start: 1, + new_count: 1, + header: Vec::new(), + lines, + } + } + + #[test] + fn absent_staged_sub_diff_yields_empty_staged_dels() { + let unstaged = file_with_hunks(vec![hunk(vec![line(LineKind::Addition, None, Some(3))])]); + let attribution = Attribution::build(Some(&unstaged), None); + assert!(attribution.staged_dels.is_empty()); + assert!(attribution.unstaged_adds.contains(&3)); + } + + #[test] + fn absent_unstaged_sub_diff_yields_empty_unstaged_adds() { + let staged = file_with_hunks(vec![hunk(vec![line(LineKind::Deletion, Some(5), None)])]); + let attribution = Attribution::build(None, Some(&staged)); + assert!(attribution.unstaged_adds.is_empty()); + assert!(attribution.staged_dels.contains(&5)); + } + + #[test] + fn both_absent_yields_two_empty_sets() { + let attribution = Attribution::build(None, None); + assert!(attribution.unstaged_adds.is_empty()); + assert!(attribution.staged_dels.is_empty()); + } + + #[test] + fn multi_hunk_files_union_across_hunks() { + let unstaged = file_with_hunks(vec![ + hunk(vec![line(LineKind::Addition, None, Some(2))]), + hunk(vec![line(LineKind::Addition, None, Some(40))]), + ]); + let staged = file_with_hunks(vec![ + hunk(vec![line(LineKind::Deletion, Some(7), None)]), + hunk(vec![line(LineKind::Deletion, Some(70), None)]), + ]); + let attribution = Attribution::build(Some(&unstaged), Some(&staged)); + assert_eq!( + attribution.unstaged_adds, + HashSet::from([2, 40]), + "adds from every hunk must be unioned, not just the first" + ); + assert_eq!( + attribution.staged_dels, + HashSet::from([7, 70]), + "dels from every hunk must be unioned, not just the first" + ); + } + + #[test] + fn no_cross_contamination_between_add_and_del_axes_at_the_same_lnum() { + // Line 9 is an UNSTAGED addition (new_lnum 9) and, independently, a STAGED deletion + // whose old_lnum happens to also be 9 — the two axes must stay on their own set, and a + // context/other-kind line at a shared lnum on the "wrong" axis must not leak in. + let unstaged = file_with_hunks(vec![hunk(vec![ + line(LineKind::Addition, None, Some(9)), + line(LineKind::Deletion, Some(9), None), // wrong axis for unstaged_adds + ])]); + let staged = file_with_hunks(vec![hunk(vec![ + line(LineKind::Deletion, Some(9), None), + line(LineKind::Addition, None, Some(9)), // wrong axis for staged_dels + ])]); + let attribution = Attribution::build(Some(&unstaged), Some(&staged)); + assert_eq!(attribution.unstaged_adds, HashSet::from([9])); + assert_eq!(attribution.staged_dels, HashSet::from([9])); + // Cross-axis membership is independent: this file has no other lnums at all, so a lookup + // for a line not present on the RIGHT axis (e.g. an add lookup for a lnum that's only a + // staged deletion) must not accidentally match. + assert!(!attribution.add_is_unstaged(100)); + assert!(!attribution.del_is_staged(100)); + } + + #[test] + fn context_lines_never_contribute_to_either_set() { + let unstaged = file_with_hunks(vec![hunk(vec![line(LineKind::Context, Some(1), Some(1))])]); + let staged = file_with_hunks(vec![hunk(vec![line(LineKind::Context, Some(1), Some(1))])]); + let attribution = Attribution::build(Some(&unstaged), Some(&staged)); + assert!(attribution.unstaged_adds.is_empty()); + assert!(attribution.staged_dels.is_empty()); + } +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 2ceba1d..6a8f306 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -16,6 +16,7 @@ pub mod acquire; pub mod align; pub mod app; pub mod apply; +pub mod attribute; pub mod error; pub mod file_ops; pub mod highlight; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 4686612..b0b80e1 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -14,6 +14,7 @@ use ratatui::Frame; use crate::align::{CellKind, DisplayRow, InlineRow, Row}; use crate::app::{App, EffectiveZoom, FileView, Layout as AppLayout, Role}; +use crate::attribute::Attribution; use crate::highlight::FgSpan; use crate::model::FileStatus; use crate::wordiff::Span as WordSpan; @@ -22,6 +23,15 @@ const BG_DEL_SUBTLE: Color = Color::Rgb(60, 24, 24); const BG_DEL_STRONG: Color = Color::Rgb(120, 40, 40); const BG_ADD_SUBTLE: Color = Color::Rgb(20, 48, 24); const BG_ADD_STRONG: Color = Color::Rgb(32, 100, 48); +/// Dim/desaturated variants of the del/add pair, for staged-ness attribution (locked decision +/// #7): visibly less vivid than the plain pair but still red-tinted, so a staged change reads as +/// "already handled" without disappearing into plain context. +const BG_DEL_STAGED_SUBTLE: Color = Color::Rgb(42, 26, 28); +const BG_DEL_STAGED_STRONG: Color = Color::Rgb(64, 38, 40); +/// Dim/desaturated variants of the add pair — green-tinted counterpart of +/// [`BG_DEL_STAGED_SUBTLE`]/[`BG_DEL_STAGED_STRONG`]. +const BG_ADD_STAGED_SUBTLE: Color = Color::Rgb(24, 34, 26); +const BG_ADD_STAGED_STRONG: Color = Color::Rgb(34, 50, 38); const FG_DEFAULT: Color = Color::Gray; const FG_DIM: Color = Color::DarkGray; const FG_GUTTER: Color = Color::DarkGray; @@ -125,6 +135,80 @@ 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 +/// [`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)] +enum AttributionMode<'a> { + /// Combined view: look up each cell's staged-ness in the given [`Attribution`], built fresh + /// for the current file this frame (see [`combined_attribution`]). + Attributed(&'a Attribution), + /// Unstaged zoom pane: every changed cell IS the not-yet-staged set — render bright, + /// unconditionally (today's plain colors). + Plain, + /// Staged zoom pane (single-zoom or the split's bottom pane): every changed cell IS already + /// staged — render dim, unconditionally. + StagedUniform, +} + +/// Build the current file's [`Attribution`] when rendering the combined role, `None` for the +/// unstaged/staged roles (which don't need a per-cell lookup — see [`AttributionMode`]). Computed +/// fresh from the sub-models on every call rather than cached on `App`: cheap (O(hunk lines) on +/// one file) and always correct even if the index changes between frames (the M4 watcher's +/// concern, not this one's, but the cost of getting it wrong is a stale color). +fn combined_attribution(app: &App, idx: usize, role: Role) -> Option { + if role != Role::Combined { + return None; + } + let unstaged = app.role_change(idx, Role::Unstaged); + let staged = app.role_change(idx, Role::Staged); + Some(Attribution::build(unstaged, staged)) +} + +/// Resolve the [`AttributionMode`] to render `role` with, given the (possibly absent, for +/// non-combined roles) [`Attribution`] built by [`combined_attribution`]. +fn attribution_mode(role: Role, attribution: &Option) -> AttributionMode<'_> { + match role { + Role::Combined => AttributionMode::Attributed( + attribution + .as_ref() + .expect("combined_attribution always builds one for Role::Combined"), + ), + Role::Unstaged => AttributionMode::Plain, + Role::Staged => AttributionMode::StagedUniform, + } +} + +/// The (subtle, strong) background pair for a Del cell at `old_lnum`, given `mode`. +fn del_bg_pair(mode: AttributionMode, old_lnum: u32) -> (Color, Color) { + match mode { + AttributionMode::Plain => (BG_DEL_SUBTLE, BG_DEL_STRONG), + AttributionMode::StagedUniform => (BG_DEL_STAGED_SUBTLE, BG_DEL_STAGED_STRONG), + AttributionMode::Attributed(attribution) => { + if attribution.del_is_staged(old_lnum) { + (BG_DEL_STAGED_SUBTLE, BG_DEL_STAGED_STRONG) + } else { + (BG_DEL_SUBTLE, BG_DEL_STRONG) + } + } + } +} + +/// The (subtle, strong) background pair for an Add cell at `new_lnum`, given `mode`. +fn add_bg_pair(mode: AttributionMode, new_lnum: u32) -> (Color, Color) { + match mode { + AttributionMode::Plain => (BG_ADD_SUBTLE, BG_ADD_STRONG), + AttributionMode::StagedUniform => (BG_ADD_STAGED_SUBTLE, BG_ADD_STAGED_STRONG), + AttributionMode::Attributed(attribution) => { + if attribution.add_is_unstaged(new_lnum) { + (BG_ADD_SUBTLE, BG_ADD_STRONG) + } else { + (BG_ADD_STAGED_SUBTLE, BG_ADD_STAGED_STRONG) + } + } + } +} + /// Which side of the aligned pair a pane line is being built for — determines which of /// [`FileView`]'s two parallel (text, highlight) sources to read. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -188,8 +272,7 @@ fn build_pane_line( kind: CellKind, word_spans: &[WordSpan], is_word_pair: bool, - subtle_bg: Color, - strong_bg: Color, + mode: AttributionMode, gutter_w: usize, content_w: usize, ) -> Line<'static> { @@ -213,7 +296,8 @@ fn build_pane_line( let mut spans = vec![TSpan::styled(gutter, Style::default().fg(FG_GUTTER))]; let emphasis = match kind { - CellKind::Del | CellKind::Add => Some((subtle_bg, strong_bg)), + CellKind::Del => Some(del_bg_pair(mode, n as u32)), + CellKind::Add => Some(add_bg_pair(mode, n as u32)), CellKind::Context | CellKind::Filler => None, }; spans.extend(content_spans(text, hl, emphasis, word_spans, is_word_pair)); @@ -452,6 +536,11 @@ fn render_pane_sbs( let new_gutter_w = gutter_width(view.new_line_count()); let end = (scroll + area.height as usize).min(view.display.len()); + // Built once per frame, not per row/cached on `App` — see `combined_attribution`'s doc + // comment. `None` for non-combined roles, which don't need it. + let attribution = combined_attribution(app, idx, role); + let mode = attribution_mode(role, &attribution); + // Phase 1 (mutable): populate the word-span cache for visible paired rows. Phase 2 below // re-borrows `app`/`view` immutably to build lines — kept as the same two-phase dance the // spike used (see app.rs's `word_spans_for_row`/`peek_word_spans` split) rather than @@ -500,8 +589,7 @@ fn render_pane_sbs( row.old_kind, &old_words, is_pair, - BG_DEL_SUBTLE, - BG_DEL_STRONG, + mode, old_gutter_w, old_area.width as usize, ); @@ -512,8 +600,7 @@ fn render_pane_sbs( row.new_kind, &new_words, is_pair, - BG_ADD_SUBTLE, - BG_ADD_STRONG, + mode, new_gutter_w, new_area.width as usize, ); @@ -567,6 +654,7 @@ fn build_inline_line( view: &FileView, row: &InlineRow, word_spans: &[WordSpan], + mode: AttributionMode, old_gutter_w: usize, new_gutter_w: usize, ) -> Line<'static> { @@ -605,10 +693,11 @@ fn build_inline_line( let mut spans = vec![TSpan::styled(gutter, Style::default().fg(FG_GUTTER))]; let is_word_pair = row.is_word_diff_pair(); - // `kind` is always Del/Add/Context here — inline has no Filler rows. + // `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 => Some((BG_DEL_SUBTLE, BG_DEL_STRONG)), - CellKind::Add => Some((BG_ADD_SUBTLE, BG_ADD_STRONG)), + CellKind::Del => old_opt.map(|n| del_bg_pair(mode, n as u32)), + CellKind::Add => new_opt.map(|n| add_bg_pair(mode, n as u32)), CellKind::Context | CellKind::Filler => None, }; spans.extend(content_spans(text, hl, emphasis, word_spans, is_word_pair)); @@ -635,6 +724,10 @@ fn render_pane_inline( let new_gutter_w = gutter_width(view.new_line_count()); let end = (scroll + area.height as usize).min(view.inline.len()); + // See `render_pane_sbs`'s identical comment — built once per frame, not cached on `App`. + let attribution = combined_attribution(app, idx, role); + let mode = attribution_mode(role, &attribution); + // Same two-phase mutable/immutable dance as `render_pane_sbs`, over the inline coordinate // space instead. if let Some(view) = app.role_view_mut(idx, role) { @@ -667,7 +760,8 @@ fn render_pane_inline( InlineRow::Add { .. } => &new_spans, _ => &[], }; - let line = build_inline_line(view, row, word_spans, old_gutter_w, new_gutter_w); + let line = + build_inline_line(view, row, word_spans, mode, old_gutter_w, new_gutter_w); let line = if is_cursor { apply_cursor_row(line, area.width) } else { @@ -687,7 +781,10 @@ mod tests { use git_workon_fixture::prelude::*; - use super::render; + use super::{ + render, BG_ADD_STAGED_STRONG, BG_ADD_STAGED_SUBTLE, BG_ADD_STRONG, BG_ADD_SUBTLE, + BG_DEL_STAGED_STRONG, BG_DEL_STAGED_SUBTLE, BG_DEL_STRONG, BG_DEL_SUBTLE, + }; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; use crate::app::App; @@ -1136,4 +1233,103 @@ mod tests { cell-for-cell for an unstaged-only file" ); } + + #[test] + fn combined_view_colors_a_staged_change_dim_and_an_unstaged_change_bright() { + // A partially-staged file with two independent word changes: line 2 was already staged + // (committed -> staged both carry the change), line 4 is still only in the worktree + // (staged -> workdir carries it, index doesn't). The combined view (HEAD <-> worktree) + // fuses both into one set of rows — attribution must tell them apart: line 2's change + // should render with the dim (staged) pair, line 4's with the bright (not-yet-staged) + // pair, on BOTH the Del (old) and Add (new) side of each row (the add/del asymmetry: + // Del keys off the staged sub-diff, Add off the unstaged sub-diff). + let committed = "l1\nold word here\nl3\nold4 word four\nl5\n"; + let staged = "l1\nnew word here\nl3\nold4 word four\nl5\n"; + let workdir = "l1\nnew word here\nl3\nnew4 word four\nl5\n"; + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .partially_staged_file("f.txt", committed, staged, workdir) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.cycle_zoom(); // Split -> Combined + assert_eq!(app.zoom, crate::app::Zoom::Combined); + // Park the cursor on the file's first (context) row so its highlight tint doesn't blend + // into either changed row's background and muddy the color comparison below. + app.cursor = 0; + app.derive_scroll(); + + let buf = render_once(&mut app, 60, 20); + let content = buf_lines(&buf); + + let staged_row = content + .iter() + .position(|line| line.contains("old word here")) + .expect("staged change's old-side text visible"); + let unstaged_row = content + .iter() + .position(|line| line.contains("old4 word four")) + .expect("unstaged change's old-side text visible"); + + // Old (left) pane, first content column after the gutter — always carries SOME del + // emphasis on a changed row, subtle or strong depending on the word-diff split, but + // always from the dim family for a staged row and the bright family for an unstaged one. + let old_content_x = 4; // gutter width 3 + 1 space, same convention as the other tests + let staged_del_bg = buf + .cell((old_content_x, staged_row as u16)) + .unwrap() + .style() + .bg; + let unstaged_del_bg = buf + .cell((old_content_x, unstaged_row as u16)) + .unwrap() + .style() + .bg; + + let dim_dels = [Some(BG_DEL_STAGED_SUBTLE), Some(BG_DEL_STAGED_STRONG)]; + let bright_dels = [Some(BG_DEL_SUBTLE), Some(BG_DEL_STRONG)]; + assert!( + dim_dels.contains(&staged_del_bg), + "expected the staged row's Del side to use the dim pair, got {staged_del_bg:?}" + ); + assert!( + bright_dels.contains(&unstaged_del_bg), + "expected the unstaged row's Del side to use the bright pair, got {unstaged_del_bg:?}" + ); + assert_ne!( + staged_del_bg, unstaged_del_bg, + "staged and unstaged Del rows must render with visibly distinct backgrounds" + ); + + // New (right) pane: same rows carry "new word here" / "new4 word four" respectively. + let left_w = (buf.area.width.saturating_sub(1)) / 2; + let new_content_x = left_w + 1 + 4; // divider + gutter width 3 + 1 space + let staged_add_bg = buf + .cell((new_content_x, staged_row as u16)) + .unwrap() + .style() + .bg; + let unstaged_add_bg = buf + .cell((new_content_x, unstaged_row as u16)) + .unwrap() + .style() + .bg; + + let dim_adds = [Some(BG_ADD_STAGED_SUBTLE), Some(BG_ADD_STAGED_STRONG)]; + let bright_adds = [Some(BG_ADD_SUBTLE), Some(BG_ADD_STRONG)]; + assert!( + dim_adds.contains(&staged_add_bg), + "expected the staged row's Add side to use the dim pair, got {staged_add_bg:?}" + ); + assert!( + bright_adds.contains(&unstaged_add_bg), + "expected the unstaged row's Add side to use the bright pair, got {unstaged_add_bg:?}" + ); + assert_ne!( + staged_add_bg, unstaged_add_bg, + "staged and unstaged Add rows must render with visibly distinct backgrounds" + ); + } } From 9fcb978ecd7e141a2d490ca10bb2bc2a7d52fd1f Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 00:50:09 -0400 Subject: [PATCH 031/203] feat(review): add transient footer notice line --- git-workon-review/src/app.rs | 71 +++++++++++++++++++++++++++++ git-workon-review/src/render.rs | 80 +++++++++++++++++++++++++++++---- git-workon-review/src/tui.rs | 77 ++++++++++++++++++++++++++++++- 3 files changed, 219 insertions(+), 9 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index cd180a5..c44fb4a 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -477,6 +477,28 @@ pub struct App { /// 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, + /// A transient, footer-rendered message — set by [`Self::notify`], cleared by + /// [`Self::clear_notice`] (the latter called by the event loop on the next keypress, so a + /// notice stays visible until the user acts). `None` renders the footer's normal hint string + /// instead (see `render::render_footer`). + pub notice: Option, +} + +/// How severely a [`Notice`] should read in the footer — decides its color (see +/// `render::FG_ERROR`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Severity { + Error, + Info, +} + +/// A transient footer message: the text to show and how severely to color it. Set via +/// [`App::notify`]; producers are the confirm/discard flows landing in m4-staging (refusals, +/// errors) — this crate has no in-crate caller yet, which is expected for a `pub` API this early. +#[derive(Debug, Clone)] +pub struct Notice { + pub text: String, + pub severity: Severity, } impl App { @@ -514,6 +536,7 @@ impl App { layout: Layout::default(), zoom: Zoom::default(), split_focus: SplitPane::Unstaged, + notice: None, } } @@ -955,6 +978,20 @@ impl App { } self.derive_scroll(); } + + /// Set a transient footer notice (see [`Self::notice`]'s doc comment). Overwrites any + /// currently-showing notice rather than queuing — only one message is ever on screen. + pub fn notify(&mut self, text: impl Into, severity: Severity) { + self.notice = Some(Notice { + text: text.into(), + severity, + }); + } + + /// Dismiss the current footer notice, if any (a no-op if there isn't one). + pub fn clear_notice(&mut self) { + self.notice = None; + } } /// Index of the [`FileChange`] in a role's [`DiffModel`] that corresponds to combined `file`, or @@ -1826,4 +1863,38 @@ mod tests { "focus toggle must do nothing when the file isn't a split" ); } + + #[test] + fn notify_sets_a_notice_with_the_given_text_and_severity() { + use super::Severity; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + assert!(app.notice.is_none(), "no notice by default"); + + app.notify("something went wrong", Severity::Error); + let notice = app.notice.as_ref().expect("notice set by notify"); + assert_eq!(notice.text, "something went wrong"); + assert_eq!(notice.severity, Severity::Error); + } + + #[test] + fn clear_notice_clears_a_set_notice() { + use super::Severity; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + + app.notify("saved", Severity::Info); + assert!(app.notice.is_some()); + + app.clear_notice(); + assert!(app.notice.is_none(), "clear_notice must clear a set notice"); + } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index b0b80e1..4cd59cc 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -13,7 +13,7 @@ use ratatui::widgets::Paragraph; use ratatui::Frame; use crate::align::{CellKind, DisplayRow, InlineRow, Row}; -use crate::app::{App, EffectiveZoom, FileView, Layout as AppLayout, Role}; +use crate::app::{App, EffectiveZoom, FileView, Layout as AppLayout, Notice, Role, Severity}; use crate::attribute::Attribution; use crate::highlight::FgSpan; use crate::model::FileStatus; @@ -34,6 +34,9 @@ const BG_ADD_STAGED_SUBTLE: Color = Color::Rgb(24, 34, 26); const BG_ADD_STAGED_STRONG: Color = Color::Rgb(34, 50, 38); const FG_DEFAULT: Color = Color::Gray; const FG_DIM: Color = Color::DarkGray; +/// Footer text color for an [`Severity::Error`] [`Notice`] — a clearly-red tone that reads on +/// both light and dark terminal themes. +const FG_ERROR: Color = Color::Rgb(220, 60, 60); const FG_GUTTER: Color = Color::DarkGray; /// Tint blended into the cursor row's background (see [`blend_bg`]) — a cool slate-blue, chosen /// to read as "cursor here" without competing with the warm del/add hues above. @@ -323,7 +326,7 @@ pub fn render(frame: &mut Frame, app: &mut App) { let footer_area = vlayout[2]; render_header(frame, app, header_area); - render_footer(frame, footer_area); + render_footer(frame, app, footer_area); render_body(frame, app, body_area); } @@ -349,12 +352,26 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { ); } -fn render_footer(frame: &mut Frame, area: Rect) { - let text = "j/k scroll ]f/[f file ]h/[h hunk L layout z zoom w focus q quit"; - frame.render_widget( - Paragraph::new(text).style(Style::default().fg(FG_DIM)), - area, - ); +fn render_footer(frame: &mut Frame, app: &App, area: Rect) { + match &app.notice { + Some(Notice { text, severity }) => { + let fg = match severity { + Severity::Error => FG_ERROR, + Severity::Info => FG_DEFAULT, + }; + frame.render_widget( + Paragraph::new(text.as_str()).style(Style::default().fg(fg)), + area, + ); + } + None => { + let text = "j/k scroll ]f/[f file ]h/[h hunk L layout z zoom w focus q quit"; + frame.render_widget( + Paragraph::new(text).style(Style::default().fg(FG_DIM)), + area, + ); + } + } } /// Write a gap row's `··· N unchanged lines ···` marker across the FULL body width (both panes @@ -1332,4 +1349,51 @@ mod tests { "staged and unstaged Add rows must render with visibly distinct backgrounds" ); } + + #[test] + fn footer_shows_hint_string_when_no_notice_is_set() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + assert!(app.notice.is_none()); + + let buf = render_once(&mut app, 80, 10); + let footer_y = buf.area.height - 1; + let footer: String = (0..buf.area.width) + .map(|x| cell_text(&buf, x, footer_y)) + .collect(); + assert!( + footer.contains("j/k scroll"), + "expected the hint string in the footer, got: {footer:?}" + ); + } + + #[test] + fn footer_shows_an_error_notice_in_the_error_fg_color() { + use crate::app::Severity; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.notify("cannot discard: nothing staged", Severity::Error); + + let buf = render_once(&mut app, 80, 10); + let footer_y = buf.area.height - 1; + let footer: String = (0..buf.area.width) + .map(|x| cell_text(&buf, x, footer_y)) + .collect(); + assert!( + footer.contains("cannot discard: nothing staged"), + "expected the notice text in the footer, got: {footer:?}" + ); + assert_eq!( + buf.cell((0, footer_y)).unwrap().style().fg, + Some(super::FG_ERROR), + "expected the error notice to render in the error fg color" + ); + } } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index ecd2910..3d46fdf 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -128,9 +128,17 @@ fn apply_action(app: &mut App, action: Action) -> bool { /// Apply one [`AppEvent`] to `app`. Returns `true` when the loop should exit (q/Esc). Resize and /// Tick are no-ops today — ratatui re-measures `body_area` every frame regardless, and Tick /// exists for M4's periodic-refresh consumers, not M3's read-only loop. +/// +/// A `Key` event clears any showing footer notice BEFORE applying the key's own action, so a +/// notice stays visible until the user's next keystroke — that same keystroke both dismisses the +/// message and performs its normal action. `Resize`/`Tick` do NOT clear it: a redraw or timer +/// tick isn't the user acting on the message. fn update(app: &mut App, pending: &mut Option, event: AppEvent) -> bool { match event { - AppEvent::Key(key) => apply_action(app, map_key(pending, key, app.pane_height)), + AppEvent::Key(key) => { + app.clear_notice(); + apply_action(app, map_key(pending, key, app.pane_height)) + } AppEvent::Resize(_, _) | AppEvent::Tick => false, } } @@ -346,4 +354,71 @@ mod tests { "pending bracket must be cleared, not left dangling" ); } + + /// Build an [`App`] straight from a fixture's repo, for `tui`'s own event-loop tests. `app.rs` + /// has an identical private helper (`test_support::app_from_fixture`), but that's + /// `pub(crate)` to the `workon_review` LIB crate — invisible here, since `tui.rs` compiles + /// into the separate bin crate (see `main.rs`'s `mod tui;`). Not worth promoting the lib's + /// helper to `pub` just to share four lines across a crate boundary. + fn app_from_fixture(fixture: &git_workon_fixture::fixture::Fixture) -> App { + use git2::Repository; + use workon_review::acquire::diff_uncommitted; + + let repo = fixture.repo().expect("fixture repo"); + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let owned = Repository::open(repo.workdir().expect("fixture has a workdir")) + .expect("reopen fixture repo"); + App::new(owned, diffs) + } + + #[test] + fn key_event_through_update_clears_a_previously_set_notice() { + use git_workon_fixture::prelude::*; + use workon_review::app::Severity; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + let mut pending = None; + + app.notify("something happened", Severity::Info); + assert!(app.notice.is_some()); + + // Any key — even one that maps to no action — dismisses the notice. + update( + &mut app, + &mut pending, + AppEvent::Key(key(KeyCode::Char('x'))), + ); + assert!( + app.notice.is_none(), + "a Key event must clear a showing notice" + ); + } + + #[test] + fn tick_and_resize_events_do_not_clear_a_notice() { + use git_workon_fixture::prelude::*; + use workon_review::app::Severity; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + let mut pending = None; + + app.notify("something happened", Severity::Info); + + update(&mut app, &mut pending, AppEvent::Tick); + assert!(app.notice.is_some(), "a Tick event must not clear a notice"); + + update(&mut app, &mut pending, AppEvent::Resize(80, 24)); + assert!( + app.notice.is_some(), + "a Resize event must not clear a notice" + ); + } } From e9ab749cd56a0c98c66fe3bab47193c57b3f2add Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 01:02:06 -0400 Subject: [PATCH 032/203] feat(review): rebuild diff state in place on manual refresh --- git-workon-review/src/app.rs | 264 ++++++++++++++++++++++++++++++-- git-workon-review/src/render.rs | 3 +- git-workon-review/src/tui.rs | 37 +++++ 3 files changed, 289 insertions(+), 15 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index c44fb4a..33f4d2b 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -14,7 +14,7 @@ use std::path::Path; use git2::Repository; -use crate::acquire::WorktreeDiffs; +use crate::acquire::{diff_uncommitted, WorktreeDiffs}; use crate::align::{align_file, collapse_gaps, inline_rows, CellKind, DisplayRow, InlineRow, Row}; use crate::highlight::{FgSpan, TsHighlighter}; use crate::model::{DiffModel, FileChange, FileStatus}; @@ -503,23 +503,19 @@ pub struct Notice { impl App { pub fn new(repo: Repository, diffs: WorktreeDiffs) -> Self { - let WorktreeDiffs { - staged, - unstaged, - combined, - } = diffs; - let files = combined.files; + let DiffState { + files, + unstaged_model, + staged_model, + unstaged_idx, + staged_idx, + } = DiffState::from(diffs); let n = files.len(); - let unstaged_idx = files - .iter() - .map(|f| find_role_change(&unstaged, f)) - .collect(); - let staged_idx = files.iter().map(|f| find_role_change(&staged, f)).collect(); Self { repo, files, - unstaged_model: unstaged, - staged_model: staged, + unstaged_model, + staged_model, unstaged_idx, staged_idx, views_combined: (0..n).map(|_| None).collect(), @@ -540,6 +536,66 @@ impl App { } } + /// Re-run [`diff_uncommitted`] and rebuild every diff-derived field in place — the operation + /// both a manual refresh (`r`) and (later) a post-staging-op/external-write refresh need. See + /// the M4 plan's changeset 5 for the full contract; summarized: + /// + /// - Rebuilds exactly what [`Self::new`] builds from a fresh [`WorktreeDiffs`]: `files`, + /// `unstaged_model`/`staged_model`, `unstaged_idx`/`staged_idx`, and all three `views_*` + /// (reset to `None` — lazily reloaded, same as a fresh `App`). + /// - Does NOT touch `repo` (same handle), `highlighter` (its per-instance grammar cache would + /// have to re-parse every language from scratch if rebuilt), `base_label`, `layout`, or + /// `zoom` (the user's current view mode shouldn't reset just because they pressed `r`, or + /// because a background refresh fired). + /// - Preserves position by the current file's PATH: if a file with that path still exists in + /// the rebuilt list, `current` follows it (even if its index moved, e.g. a file alphabetically + /// before it in the old list got fully staged away). If it vanished (fully staged or + /// reverted), `current` clamps into the new list (or `0` if it's now empty). + /// - Re-seats the (possibly changed) current file at its first hunk via [`Self::open_current`] + /// — the same path a file switch already uses. This does NOT try to preserve the exact + /// cursor row: the rows under an old cursor position may no longer correspond to the same + /// content once the diff is rebuilt, so jumping to the first hunk (like opening a file fresh) + /// is the only always-valid choice, consistent with how zoom/layout switches already treat + /// cursor position as non-transferable across a reshape. + /// + /// On a [`diff_uncommitted`] error, leaves all existing state untouched and sets an error + /// [`Notice`] instead (via [`Self::notify`]) — a failed refresh must never blank the review. + pub fn refresh(&mut self) { + let diffs = match diff_uncommitted(&self.repo) { + Ok(diffs) => diffs, + Err(err) => { + self.notify(format!("refresh failed: {err}"), Severity::Error); + return; + } + }; + + let current_path = self.files.get(self.current).map(|f| f.path.clone()); + + let DiffState { + files, + unstaged_model, + staged_model, + unstaged_idx, + staged_idx, + } = DiffState::from(diffs); + let n = files.len(); + + self.current = current_path + .and_then(|path| files.iter().position(|f| f.path == path)) + .unwrap_or(if n == 0 { 0 } else { self.current.min(n - 1) }); + + self.files = files; + self.unstaged_model = unstaged_model; + self.staged_model = staged_model; + self.unstaged_idx = unstaged_idx; + self.staged_idx = staged_idx; + self.views_combined = (0..n).map(|_| None).collect(); + self.views_unstaged = (0..n).map(|_| None).collect(); + self.views_staged = (0..n).map(|_| None).collect(); + + self.open_current(); + } + /// Resolve the [`EffectiveZoom`] for file `idx` this frame: the requested [`Self::zoom`] gated /// against that file's available sub-diffs and stageability. Cheap (three lookups + the pure /// [`effective_zoom`]) — re-evaluated per file per frame, no caching (locked decision #3). @@ -994,6 +1050,41 @@ impl App { } } +/// The diff-derived pieces [`App::new`] and [`App::refresh`] both build fresh from a +/// [`WorktreeDiffs`] snapshot — everything EXCEPT the view caches (which the two callers reset +/// differently sized `None` vectors for) and the navigation/UI state that survives a refresh +/// (`current`, `cursor`, `layout`, `zoom`, etc. — see [`App::refresh`]'s doc comment). +struct DiffState { + files: Vec, + unstaged_model: DiffModel, + staged_model: DiffModel, + unstaged_idx: Vec>, + staged_idx: Vec>, +} + +impl From for DiffState { + fn from(diffs: WorktreeDiffs) -> Self { + let WorktreeDiffs { + staged, + unstaged, + combined, + } = diffs; + let files = combined.files; + let unstaged_idx = files + .iter() + .map(|f| find_role_change(&unstaged, f)) + .collect(); + let staged_idx = files.iter().map(|f| find_role_change(&staged, f)).collect(); + Self { + files, + unstaged_model: unstaged, + staged_model: staged, + unstaged_idx, + staged_idx, + } + } +} + /// Index of the [`FileChange`] in a role's [`DiffModel`] that corresponds to combined `file`, or /// `None` when the role has no change for it (e.g. an untracked file in the staged model). /// @@ -1897,4 +1988,149 @@ mod tests { app.clear_notice(); assert!(app.notice.is_none(), "clear_notice must clear a set notice"); } + + // ---- M4 refresh: in-place re-diff + rebuild ------------------------------------------- + + #[test] + fn refresh_after_external_worktree_edit_picks_up_the_change() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert!(!app.current_view_ref().unwrap().new_text().contains("THREE")); + + // Mutate the fixture repo's WORKTREE directly (not this crate's own working tree) — an + // edit made outside the TUI, same as a user switching to another editor mid-review. + let repo = fixture.repo().unwrap(); + let workdir = repo.workdir().unwrap(); + std::fs::write(workdir.join("a.txt"), "one\nCHANGED\nTHREE\n").unwrap(); + + app.refresh(); + + let view = app + .current_view_ref() + .expect("current file still has a view after refresh"); + assert!( + view.new_text().contains("THREE"), + "refresh must re-read the worktree file, got: {:?}", + view.new_text() + ); + } + + #[test] + fn refresh_preserves_the_current_file_by_path() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .unstaged_file("b.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.next_file(); + assert_eq!(app.current, 1, "path-sorted: b.txt is index 1"); + let path = app.files[app.current].path.clone(); + + app.refresh(); + + assert_eq!( + app.files[app.current].path, path, + "refresh must keep tracking the same file by path" + ); + } + + #[test] + fn refresh_when_the_current_file_vanished_clamps_without_panicking() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .unstaged_file("b.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.next_file(); + assert_eq!(app.current, 1, "path-sorted: b.txt is index 1"); + + // Revert b.txt's worktree copy back to its committed content, outside the TUI — its dirt + // disappears, so the rebuilt file list no longer has an entry for it. + let repo = fixture.repo().unwrap(); + let workdir = repo.workdir().unwrap(); + std::fs::write(workdir.join("b.txt"), "one\n").unwrap(); + + app.refresh(); + + assert_eq!(app.files.len(), 1, "only a.txt is still dirty"); + assert!( + app.current < app.files.len(), + "current must be clamped in-range, got {}", + app.current + ); + assert_eq!(app.files[app.current].path, "a.txt"); + } + + #[test] + fn refresh_failure_leaves_state_intact_and_sets_an_error_notice() { + use super::Severity; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + let files_before: Vec = app.files.iter().map(|f| f.path.clone()).collect(); + + // Corrupt the throwaway fixture repo's OWN `.git/HEAD` so `diff_uncommitted`'s + // `repo.head()` call fails cheaply — never done against a real working tree. + let repo = fixture.repo().unwrap(); + std::fs::write(repo.path().join("HEAD"), b"garbage-not-a-ref\n").unwrap(); + + app.refresh(); + + let files_after: Vec = app.files.iter().map(|f| f.path.clone()).collect(); + assert_eq!( + files_after, files_before, + "a failed refresh must leave existing state untouched" + ); + let notice = app + .notice + .as_ref() + .expect("refresh failure must set a notice"); + assert_eq!(notice.severity, Severity::Error); + assert!( + notice.text.contains("refresh failed"), + "got notice text: {:?}", + notice.text + ); + } + + #[test] + fn refresh_preserves_zoom_and_layout() { + use super::{Layout, Zoom}; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.layout = Layout::Inline; + app.zoom = Zoom::Combined; + + app.refresh(); + + assert_eq!(app.layout, Layout::Inline, "refresh must not reset layout"); + assert_eq!(app.zoom, Zoom::Combined, "refresh must not reset zoom"); + } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 4cd59cc..fb153f1 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -365,7 +365,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ); } None => { - let text = "j/k scroll ]f/[f file ]h/[h hunk L layout z zoom w focus q quit"; + let text = + "j/k scroll ]f/[f file ]h/[h hunk L layout z zoom w focus r refresh q quit"; frame.render_widget( Paragraph::new(text).style(Style::default().fg(FG_DIM)), area, diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 3d46fdf..e633078 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -60,6 +60,7 @@ enum Action { ToggleLayout, CycleZoom, ToggleSplitFocus, + Refresh, None, } @@ -92,6 +93,7 @@ fn map_key(pending: &mut Option, key: KeyEvent, pane_height: usize) -> Act KeyCode::Char('L') => Action::ToggleLayout, KeyCode::Char('z') => Action::CycleZoom, KeyCode::Char('w') => Action::ToggleSplitFocus, + KeyCode::Char('r') => Action::Refresh, KeyCode::Tab => Action::NextFile, KeyCode::BackTab => Action::PrevFile, KeyCode::Char(']') => { @@ -120,6 +122,7 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::ToggleLayout => app.toggle_layout(), Action::CycleZoom => app.cycle_zoom(), Action::ToggleSplitFocus => app.toggle_split_focus(), + Action::Refresh => app.refresh(), Action::None => {} } false @@ -288,6 +291,15 @@ mod tests { ); } + #[test] + fn r_maps_to_refresh() { + let mut pending = None; + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('r')), 20), + Action::Refresh + ); + } + #[test] fn tab_and_backtab_map_to_file_nav() { let mut pending = None; @@ -398,6 +410,31 @@ mod tests { ); } + #[test] + fn r_key_through_update_refreshes_without_panicking() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let mut pending = None; + + update( + &mut app, + &mut pending, + AppEvent::Key(key(KeyCode::Char('r'))), + ); + + // A no-op refresh (nothing changed externally) still rebuilds the view in place; the + // smoke test is simply that this doesn't panic and the file is still there. + assert_eq!(app.files.len(), 1); + assert_eq!(app.files[0].path, "a.txt"); + } + #[test] fn tick_and_resize_events_do_not_clear_a_notice() { use git_workon_fixture::prelude::*; From 454d5223e06fb0179a793fa863a8a8814f7d53d3 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 01:23:00 -0400 Subject: [PATCH 033/203] feat(review): stage and discard hunks and files via the queue --- git-workon-review/src/app.rs | 672 +++++++++++++++++++++++++++++- git-workon-review/src/lib.rs | 1 + git-workon-review/src/render.rs | 43 +- git-workon-review/src/stage_op.rs | 79 ++++ git-workon-review/src/tui.rs | 104 +++++ 5 files changed, 896 insertions(+), 3 deletions(-) create mode 100644 git-workon-review/src/stage_op.rs diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 33f4d2b..ec11993 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -16,8 +16,11 @@ use git2::Repository; use crate::acquire::{diff_uncommitted, WorktreeDiffs}; use crate::align::{align_file, collapse_gaps, inline_rows, CellKind, DisplayRow, InlineRow, Row}; +use crate::apply::{Git2Applier, StageVerb}; use crate::highlight::{FgSpan, TsHighlighter}; -use crate::model::{DiffModel, FileChange, FileStatus}; +use crate::model::{DiffModel, FileChange, FileStatus, Hunk}; +use crate::queue::{OpOutcome, StagingQueue}; +use crate::stage_op::FileStagingOp; use crate::wordiff::{word_diff_spans, Span}; /// Minimum rows kept between the cursor and the top/bottom of the pane while scrolling — see @@ -70,6 +73,15 @@ pub struct FileView { /// `InlineRow` entries at different indices instead of one `AlignedRow`), so the two caches /// cannot share keys. inline_word_spans: HashMap, Vec)>, + /// Which hunk (index into the file's `hunks`) each [`Self::display`] row belongs to, or + /// `None` for a row outside every hunk's span (a collapsed gap, or the leading/trailing + /// context that lies beyond any `@@` block). Computed once at [`Self::load`] so staging ops + /// can resolve "the hunk under the cursor" without re-walking the diff — see + /// [`Self::hunk_at_display_row`]. A SEPARATE vector per coordinate space, exactly like the + /// two word-span caches, since `display` and `inline` disagree on row count/index. + display_hunk: Vec>, + /// Inline-coordinate analog of [`Self::display_hunk`], indexed against [`Self::inline`]. + inline_hunk: Vec>, } impl FileView { @@ -128,6 +140,21 @@ impl FileView { .position(is_inline_hunk_content_row) .unwrap_or(0); + let display_hunk = display + .iter() + .map(|row| { + let (old, new) = display_row_linenos(row); + hunk_for_linenos(&file.hunks, old, new) + }) + .collect(); + let inline_hunk = inline + .iter() + .map(|row| { + let (old, new) = inline_row_linenos(row); + hunk_for_linenos(&file.hunks, old, new) + }) + .collect(); + Self { old_text, new_text, @@ -141,9 +168,23 @@ impl FileView { word_spans: HashMap::new(), inline, inline_word_spans: HashMap::new(), + display_hunk, + inline_hunk, } } + /// The hunk (index into the file's `hunks`) whose span covers display row `row`, or `None` + /// for a row outside every hunk (a gap, or context beyond any `@@` block). A context line git + /// kept inside a hunk's header counts as "in" that hunk (matching the prototype's `hunk_at`). + pub(crate) fn hunk_at_display_row(&self, row: usize) -> Option { + self.display_hunk.get(row).copied().flatten() + } + + /// Inline-coordinate analog of [`Self::hunk_at_display_row`]. + pub(crate) fn hunk_at_inline_row(&self, row: usize) -> Option { + self.inline_hunk.get(row).copied().flatten() + } + pub fn old_line(&self, n: usize) -> &str { self.old_lines .get(n.saturating_sub(1)) @@ -482,6 +523,41 @@ pub struct App { /// notice stays visible until the user acts). `None` renders the footer's normal hint string /// instead (see `render::render_footer`). pub notice: Option, + /// FIFO queue every staging verb enqueues through, then drains on the same beat (locked + /// decision #5). Going through the queue (rather than calling `ops::apply_*` directly) buys + /// the queue's lock-retry and panic isolation for free; because the drain is synchronous and + /// a refresh follows before the next keystroke, only ever one op is in flight. + queue: StagingQueue, + /// The default write path (M2 verdict): libgit2's `Repository::apply`. Held as the concrete + /// type — [`crate::apply::Applier`] stays a trait for the CLI escape hatch, but the field is + /// the default. + applier: Git2Applier, + /// A destructive op awaiting the user's `y`/`n`/`Esc`. Set by [`Self::request_confirm`] (the + /// discard verbs), resolved by [`Self::resolve_confirm`]. While `Some`, the event loop routes + /// `y`/`n`/`Esc` to it and IGNORES every other key (a modal capture — see `tui::update`); the + /// footer shows its prompt in place of the notice/hints. + pub pending_confirm: Option, +} + +/// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] +/// (plus a hunk index for the hunk variant) rather than by a captured closure — an enum stores +/// cleanly on [`App`] and is resolved against the live diff at `y`-time. Both variants are +/// discards; line-precise discard is the next changeset (m4-select). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PendingOp { + /// Discard one hunk of the file at `file_idx` from the worktree. `hunk_idx` indexes the + /// UNSTAGED role's hunks (discard only acts in the unstaged pane), matching where the cursor + /// resolved it. + DiscardHunk { file_idx: usize, hunk_idx: usize }, + /// Discard all of the file at `file_idx`'s worktree changes. + DiscardFile { file_idx: usize }, +} + +/// A pending destructive op plus the scope-stating prompt shown on the footer until answered. +#[derive(Debug, Clone)] +pub struct Confirm { + pub prompt: String, + pub op: PendingOp, } /// How severely a [`Notice`] should read in the footer — decides its color (see @@ -533,6 +609,9 @@ impl App { zoom: Zoom::default(), split_focus: SplitPane::Unstaged, notice: None, + queue: StagingQueue::new(), + applier: Git2Applier, + pending_confirm: None, } } @@ -1048,6 +1127,204 @@ impl App { pub fn clear_notice(&mut self) { self.notice = None; } + + /// The hunk (index into the FOCUSED role view's hunks) under the cursor, in whichever + /// coordinate space [`Self::layout`] is active — `None` when the cursor sits on context + /// outside every hunk, or there's no loaded view. Staging ops resolve their hunk target + /// through this. + pub fn hunk_at_cursor(&self) -> Option { + let view = self.current_view_ref()?; + match self.layout { + Layout::Sbs => view.hunk_at_display_row(self.cursor), + Layout::Inline => view.hunk_at_inline_row(self.cursor), + } + } + + /// The role a staging verb acts in for the current file: the single effective role, or the + /// focused split pane's role. `None` for [`Role::Combined`] — the combined view fuses both + /// sub-diffs, so staging there has no unambiguous direction and the verbs refuse (locked + /// decision #1). + fn staging_role(&self) -> Option { + match self.effective_zoom_for(self.current) { + EffectiveZoom::Single(Role::Combined) => None, + EffectiveZoom::Single(role) => Some(role), + EffectiveZoom::Split => Some(self.split_focus_role()), + } + } + + /// Toggle-direction by role (locked decision #1): the unstaged pane stages, the staged pane + /// unstages. `None` for [`Role::Combined`] (never a staging target). + fn verb_for_role(role: Role) -> Option { + match role { + Role::Unstaged => Some(StageVerb::Stage), + Role::Staged => Some(StageVerb::Unstage), + Role::Combined => None, + } + } + + /// Stage (unstaged pane) or unstage (staged pane) the hunk under the cursor (`s`). Refuses on + /// the combined view, or when the cursor isn't in a hunk. + pub fn stage_hunk(&mut self) { + if self.files.is_empty() { + return; + } + let Some(role) = self.staging_role() else { + self.notify( + "stage in the unstaged/staged pane — cycle zoom (z)", + Severity::Error, + ); + return; + }; + let Some(verb) = Self::verb_for_role(role) else { + return; + }; + let Some(hunk_idx) = self.hunk_at_cursor() else { + self.notify("no hunk under cursor", Severity::Error); + return; + }; + // The hunk index is into the ROLE's own hunks, so the op must apply against that role's + // sub-`FileChange`, not the combined one. + let Some(file) = self.role_change(self.current, role).cloned() else { + return; + }; + self.run_op(FileStagingOp::hunk(file, hunk_idx, verb)); + } + + /// Stage (unstaged pane) or unstage (staged pane) the whole current file (`S`) — ignores the + /// cursor. Refuses on the combined view. + pub fn stage_file(&mut self) { + if self.files.is_empty() { + return; + } + let Some(role) = self.staging_role() else { + self.notify( + "stage in the unstaged/staged pane — cycle zoom (z)", + Severity::Error, + ); + return; + }; + let Some(verb) = Self::verb_for_role(role) else { + return; + }; + // A whole-file op routes on path + status only ([`crate::ops::apply_file`]), which the + // combined file carries authoritatively (e.g. Untracked-ness for a discard). + let file = self.files[self.current].clone(); + self.run_op(FileStagingOp::file(file, verb)); + } + + /// Request confirmation to discard the hunk under the cursor from the worktree (`d`). Refuses + /// on the combined view, in a staged pane (discard only reverts worktree changes), or when the + /// cursor isn't in a hunk. The discard itself runs when the user answers `y`. + pub fn discard_hunk(&mut self) { + if self.files.is_empty() { + return; + } + let Some(role) = self.staging_role() else { + self.notify( + "stage in the unstaged/staged pane — cycle zoom (z)", + Severity::Error, + ); + return; + }; + if role != Role::Unstaged { + self.notify("discard acts in the unstaged pane", Severity::Error); + return; + } + let Some(hunk_idx) = self.hunk_at_cursor() else { + self.notify("no hunk under cursor", Severity::Error); + return; + }; + self.request_confirm( + "Discard this hunk from the worktree? (y/n)".to_string(), + PendingOp::DiscardHunk { + file_idx: self.current, + hunk_idx, + }, + ); + } + + /// Request confirmation to discard the whole current file's worktree changes (`D`). Refuses on + /// the combined view or in a staged pane; the discard runs on `y`. + pub fn discard_file(&mut self) { + if self.files.is_empty() { + return; + } + let Some(role) = self.staging_role() else { + self.notify( + "stage in the unstaged/staged pane — cycle zoom (z)", + Severity::Error, + ); + return; + }; + if role != Role::Unstaged { + self.notify("discard acts in the unstaged pane", Severity::Error); + return; + } + let path = self.files[self.current].path.clone(); + self.request_confirm( + format!("Discard all changes to `{path}`? (y/n)"), + PendingOp::DiscardFile { + file_idx: self.current, + }, + ); + } + + /// Set a pending confirm (see [`Self::pending_confirm`]). Overwrites any current one. + pub fn request_confirm(&mut self, prompt: impl Into, op: PendingOp) { + self.pending_confirm = Some(Confirm { + prompt: prompt.into(), + op, + }); + } + + /// Resolve a pending confirm: on `accept` run its op, then clear it either way. A no-op with + /// no confirm pending. A cancel (`accept == false`) is left silent — the cleared prompt is + /// feedback enough. + pub fn resolve_confirm(&mut self, accept: bool) { + let Some(confirm) = self.pending_confirm.take() else { + return; + }; + if !accept { + return; + } + match confirm.op { + PendingOp::DiscardHunk { file_idx, hunk_idx } => { + // The hunk index came from the unstaged pane's view, so discard against the + // unstaged role's sub-`FileChange`. + let Some(file) = self.role_change(file_idx, Role::Unstaged).cloned() else { + return; + }; + self.run_op(FileStagingOp::hunk(file, hunk_idx, StageVerb::Discard)); + } + PendingOp::DiscardFile { file_idx } => { + let Some(file) = self.files.get(file_idx).cloned() else { + return; + }; + self.run_op(FileStagingOp::file(file, StageVerb::Discard)); + } + } + } + + /// Enqueue `op`, drain the queue on the same beat, then act on the outcomes: any failure or + /// panic surfaces on the footer and does NOT refresh (the index didn't change as intended); an + /// all-`Completed` drain refreshes once, rebuilding the views + attribution from the new index + /// (locked decision #5). Only ever one op is in flight, so the queue's trap-4 staleness can't + /// arise — the queue is here for its lock-retry and panic isolation. + fn run_op(&mut self, op: FileStagingOp) { + self.queue.enqueue(op); + // Distinct fields (`queue` mutable, `repo`/`applier` shared) — the borrow checker permits + // the disjoint borrows in one call, so the queue needn't be taken out and put back. + let outcomes = self.queue.drain(&self.repo, &self.applier); + let failure = outcomes.iter().find_map(|outcome| match outcome { + OpOutcome::Failed(_, err) => Some(format!("staging failed: {err}")), + OpOutcome::Panicked(_) => Some("staging operation panicked".to_string()), + OpOutcome::Completed(_) => None, + }); + match failure { + Some(message) => self.notify(message, Severity::Error), + None => self.refresh(), + } + } } /// The diff-derived pieces [`App::new`] and [`App::refresh`] both build fresh from a @@ -1133,6 +1410,57 @@ fn is_inline_hunk_content_row(row: &InlineRow) -> bool { matches!(row, InlineRow::Del { .. } | InlineRow::Add { .. }) } +/// The 1-based line number a [`Row`] carries on its side, or `None` for a filler. +fn row_lineno(row: Row) -> Option { + match row { + Row::Line(n) => Some(n), + Row::Filler => None, + } +} + +/// The (old, new) 1-based line numbers a display row occupies — `None` on a filler side, and +/// `(None, None)` for a gap row (which belongs to no hunk). +fn display_row_linenos(row: &DisplayRow) -> (Option, Option) { + match row { + DisplayRow::Row(r) => (row_lineno(r.old), row_lineno(r.new)), + DisplayRow::Gap { .. } => (None, None), + } +} + +/// Inline-coordinate analog of [`display_row_linenos`]. +fn inline_row_linenos(row: &InlineRow) -> (Option, Option) { + match *row { + InlineRow::Context { old, new } => (Some(old), Some(new)), + InlineRow::Del { old, .. } => (Some(old), None), + InlineRow::Add { new, .. } => (None, Some(new)), + InlineRow::Gap { .. } => (None, None), + } +} + +/// Which hunk (index into `hunks`) a row occupying old line `old` / new line `new` falls in, by +/// matching its line number against each hunk's `old_start`/`old_count` (or +/// `new_start`/`new_count`) span — the same counters [`align_file`] reads. A row inside a hunk's +/// span, INCLUDING a context line git kept within the `@@` block, belongs to that hunk; a row +/// outside every span (a between-hunks gap, or leading/trailing context) is `None`. First match +/// wins on the rare touching-span boundary between two adjacent hunks. +fn hunk_for_linenos(hunks: &[Hunk], old: Option, new: Option) -> Option { + hunks.iter().position(|h| { + if let Some(o) = old { + let (start, count) = (h.old_start as usize, h.old_count as usize); + if count > 0 && o >= start && o < start + count { + return true; + } + } + if let Some(n) = new { + let (start, count) = (h.new_start as usize, h.new_count as usize); + if count > 0 && n >= start && n < start + count { + return true; + } + } + false + }) +} + /// Inline-layout analog of [`find_next_hunk_row`]: row index of the next "hunk start" (a /// `Del`/`Add` row whose predecessor is `Context`/`Gap`/absent) strictly after `after`, searching /// [`crate::app::FileView::inline`] instead of `display`. @@ -2133,4 +2461,346 @@ mod tests { assert_eq!(app.layout, Layout::Inline, "refresh must not reset layout"); assert_eq!(app.zoom, Zoom::Combined, "refresh must not reset zoom"); } + + // ---- M4 staging: hunk identity --------------------------------------------------------- + + /// A modified file whose only two changes are its first and last line, with a dozen unchanged + /// lines between — so the two hunks are far enough apart to leave a collapsed gap between + /// them (the between-hunks `None` case for the row→hunk mapping). + fn two_hunk_fixture() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "many.txt", + "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n", + "ONE\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\nTWENTY\n", + ) + .build() + .unwrap() + } + + #[test] + fn hunk_at_display_row_maps_change_rows_to_hunks_and_gap_to_none() { + let fixture = two_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let view = app.current_view_ref().unwrap(); + + // A gap row between the two hunks maps to no hunk. + let gap_row = view + .display + .iter() + .position(|r| matches!(r, DisplayRow::Gap { .. })) + .expect("a collapsed gap sits between the two far-apart hunks"); + assert_eq!(view.hunk_at_display_row(gap_row), None); + + // The earliest change row belongs to hunk 0, the latest to hunk 1. + let first_change = view + .display + .iter() + .position(|r| matches!(r, DisplayRow::Row(a) if a.old_kind != CellKind::Context)) + .expect("hunk 0 change row"); + let last_change = view + .display + .iter() + .rposition(|r| matches!(r, DisplayRow::Row(a) if a.old_kind != CellKind::Context)) + .expect("hunk 1 change row"); + assert_eq!(view.hunk_at_display_row(first_change), Some(0)); + assert_eq!(view.hunk_at_display_row(last_change), Some(1)); + } + + #[test] + fn hunk_at_inline_row_maps_change_rows_to_hunks_and_gap_to_none() { + let fixture = two_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let view = app.current_view_ref().unwrap(); + + let gap_row = view + .inline + .iter() + .position(|r| matches!(r, InlineRow::Gap { .. })) + .expect("a collapsed gap sits between the two far-apart hunks"); + assert_eq!(view.hunk_at_inline_row(gap_row), None); + + let first_change = view + .inline + .iter() + .position(|r| matches!(r, InlineRow::Del { .. } | InlineRow::Add { .. })) + .expect("hunk 0 change row"); + let last_change = view + .inline + .iter() + .rposition(|r| matches!(r, InlineRow::Del { .. } | InlineRow::Add { .. })) + .expect("hunk 1 change row"); + assert_eq!(view.hunk_at_inline_row(first_change), Some(0)); + assert_eq!(view.hunk_at_inline_row(last_change), Some(1)); + } + + #[test] + fn hunk_at_cursor_dispatches_on_layout_and_reports_none_between_hunks() { + let fixture = two_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); // cursor lands on the first hunk + assert_eq!(app.hunk_at_cursor(), Some(0)); + + // Park the cursor on the gap row → no hunk under it. + let gap_row = app + .current_view_ref() + .unwrap() + .display + .iter() + .position(|r| matches!(r, DisplayRow::Gap { .. })) + .unwrap(); + app.cursor = gap_row; + assert_eq!(app.hunk_at_cursor(), None); + } + + // ---- M4 staging: verbs ----------------------------------------------------------------- + + /// A file with three distinct HEAD/index/worktree states — both a staged and an unstaged + /// sub-diff, and hunk-patchable (Modified). Same shape the zoom tests use. + fn partial_fixture() -> 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() + } + + #[test] + fn stage_hunk_in_unstaged_pane_stages_the_hunk() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); // downgrades to a single unstaged pane; cursor on the hunk + app.stage_hunk(); + + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::has_staged_file("a.txt")); + // The worktree copy is untouched by a stage. + repo.assert(predicate::repo::workdir_file_equals( + "a.txt", + "one\nCHANGED\n", + )); + } + + #[test] + fn stage_hunk_in_staged_pane_unstages_the_hunk() { + use super::Zoom; + + let fixture = partial_fixture(); + let mut app = app_from_fixture(&fixture); + app.zoom = Zoom::Staged; + app.open_current(); + app.stage_hunk(); // staged pane → unstage direction + + // Unstaging the only staged hunk reverts the index entry to HEAD. + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::index_blob_equals( + "f.txt", + "alpha\nbeta\ngamma\n", + )); + } + + #[test] + fn stage_file_in_unstaged_pane_stages_whole_file() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.stage_file(); + + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::has_staged_file("a.txt")); + } + + #[test] + fn stage_file_in_staged_pane_unstages_whole_file() { + use super::Zoom; + + // A freshly `git add`ed (Added) file has only a staged sub-diff. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("new.txt", "hello\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.zoom = Zoom::Staged; + app.open_current(); + app.stage_file(); // staged pane → unstage; Added file has no HEAD entry, so it goes untracked + + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::has_untracked_file("new.txt")); + } + + // ---- M4 staging: discard confirm flow -------------------------------------------------- + + #[test] + fn discard_hunk_requests_confirm_then_y_reverts_the_worktree() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.discard_hunk(); + + // Requesting a confirm must NOT mutate anything yet. + assert!( + app.pending_confirm.is_some(), + "discard must request a confirm" + ); + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::workdir_file_equals( + "a.txt", + "one\nCHANGED\n", + )); + + app.resolve_confirm(true); + assert!(app.pending_confirm.is_none(), "y must clear the confirm"); + // Discard reverts the worktree hunk back to the index (== HEAD here). + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::workdir_file_equals("a.txt", "one\ntwo\n")); + } + + #[test] + fn discard_confirm_n_cancels_and_leaves_the_worktree_unchanged() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.discard_hunk(); + app.resolve_confirm(false); + + assert!(app.pending_confirm.is_none(), "n must clear the confirm"); + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::workdir_file_equals( + "a.txt", + "one\nCHANGED\n", + )); + } + + #[test] + fn discard_file_requests_confirm_then_y_reverts_the_whole_worktree_file() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\nthree\n", "ONE\ntwo\nTHREE\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.discard_file(); + assert!(app.pending_confirm.is_some()); + + app.resolve_confirm(true); + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::workdir_file_equals( + "a.txt", + "one\ntwo\nthree\n", + )); + } + + // ---- M4 staging: refusals -------------------------------------------------------------- + + #[test] + fn stage_hunk_in_combined_view_refuses_without_touching_the_index() { + use super::{Severity, Zoom}; + + let fixture = partial_fixture(); + let mut app = app_from_fixture(&fixture); + app.zoom = Zoom::Combined; + app.open_current(); + app.stage_hunk(); + + let notice = app.notice.as_ref().expect("combined stage must refuse"); + assert_eq!(notice.severity, Severity::Error); + assert!(notice.text.contains("cycle zoom"), "got: {:?}", notice.text); + // The index is untouched — still the originally-staged content. + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::index_blob_equals( + "f.txt", + "alpha\nBETAEDIT\ngamma\n", + )); + } + + #[test] + fn discard_hunk_in_staged_pane_refuses() { + use super::{Severity, Zoom}; + + let fixture = partial_fixture(); + let mut app = app_from_fixture(&fixture); + app.zoom = Zoom::Staged; + app.open_current(); + app.discard_hunk(); + + assert!( + app.pending_confirm.is_none(), + "a staged-pane discard must refuse, not request a confirm" + ); + let notice = app.notice.as_ref().expect("staged discard must refuse"); + assert_eq!(notice.severity, Severity::Error); + assert!( + notice.text.contains("unstaged pane"), + "got: {:?}", + notice.text + ); + } + + #[test] + fn stage_hunk_between_hunks_refuses_with_no_hunk_under_cursor() { + use super::Severity; + + let fixture = two_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + // Park the cursor on the gap row between the two hunks. + let gap_row = app + .current_view_ref() + .unwrap() + .display + .iter() + .position(|r| matches!(r, DisplayRow::Gap { .. })) + .unwrap(); + app.cursor = gap_row; + app.stage_hunk(); + + let notice = app + .notice + .as_ref() + .expect("between-hunks stage must refuse"); + assert_eq!(notice.severity, Severity::Error); + assert!( + notice.text.contains("no hunk under cursor"), + "got: {:?}", + notice.text + ); + // Nothing was staged. + let repo = fixture.repo().unwrap(); + assert!( + !predicate::repo::has_staged_file("many.txt").eval(repo), + "a refused stage must not touch the index" + ); + } } diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 6a8f306..28b2de9 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -25,5 +25,6 @@ pub mod ops; pub mod queue; pub mod refresh; pub mod render; +pub mod stage_op; pub mod synthesis; pub mod wordiff; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index fb153f1..5aa38f8 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -352,7 +352,16 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { ); } +/// Footer priority: a pending discard confirm's prompt (warn-toned) wins over a transient notice, +/// which wins over the dim hint line. fn render_footer(frame: &mut Frame, app: &App, area: Rect) { + if let Some(confirm) = &app.pending_confirm { + frame.render_widget( + Paragraph::new(confirm.prompt.as_str()).style(Style::default().fg(FG_ERROR)), + area, + ); + return; + } match &app.notice { Some(Notice { text, severity }) => { let fg = match severity { @@ -365,8 +374,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ); } None => { - let text = - "j/k scroll ]f/[f file ]h/[h hunk L layout z zoom w focus r refresh q quit"; + let text = "j/k scroll s/S stage d/D discard z zoom w focus r refresh q quit"; frame.render_widget( Paragraph::new(text).style(Style::default().fg(FG_DIM)), area, @@ -1397,4 +1405,35 @@ mod tests { "expected the error notice to render in the error fg color" ); } + + #[test] + fn footer_shows_a_pending_confirm_prompt_over_any_notice() { + use crate::app::{PendingOp, Severity}; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + // A notice is set, but a pending confirm outranks it on the footer surface. + app.notify("some earlier notice", Severity::Info); + app.request_confirm( + "Discard this hunk from the worktree? (y/n)", + PendingOp::DiscardFile { file_idx: 0 }, + ); + + let buf = render_once(&mut app, 80, 10); + let footer_y = buf.area.height - 1; + let footer: String = (0..buf.area.width) + .map(|x| cell_text(&buf, x, footer_y)) + .collect(); + assert!( + footer.contains("Discard this hunk from the worktree?"), + "expected the confirm prompt in the footer, got: {footer:?}" + ); + assert!( + !footer.contains("some earlier notice"), + "the confirm prompt must take priority over the notice, got: {footer:?}" + ); + } } diff --git a/git-workon-review/src/stage_op.rs b/git-workon-review/src/stage_op.rs new file mode 100644 index 0000000..561f636 --- /dev/null +++ b/git-workon-review/src/stage_op.rs @@ -0,0 +1,79 @@ +//! The concrete [`StagingOp`] the TUI enqueues for a hunk- or file-level staging verb (M4). +//! +//! ## The error seam +//! +//! [`crate::ops::apply_hunk`]/[`crate::ops::apply_file`] return [`ReviewError`], but the queue's +//! [`StagingOp::run`] contract is `Result<(), ApplyError>` — because the queue's one-shot +//! lock-retry keys off [`ApplyError::IndexLocked`]/[`crate::apply::is_lock_contention`], which +//! only lives inside the `Apply` arm. So [`FileStagingOp::run`] maps: +//! +//! - `Err(ReviewError::Apply(e))` → `Err(e)` — preserving the `ApplyError` (and thus the lock +//! classification) so the queue's retry still fires. +//! - `Err(ReviewError::Synthesis | ReviewError::Git | ReviewError::Diff)` → a single defensive +//! [`ApplyError::Io`] wrapping the message. These are not expected for a hunk/file verb on a +//! pre-validated target: `apply_hunk`/`apply_file` ROUTE whole-file statuses rather than +//! refuse, so a synthesis/git/diff error here means an assumption broke, not a normal outcome. +//! Wrapping (rather than mapping to `Ok`) surfaces it on the footer instead of silently +//! pretending the op succeeded. + +use crate::apply::StageVerb; +use crate::error::{ApplyError, ReviewError}; +use crate::model::FileChange; +use crate::ops; +use crate::queue::{OpContext, StagingOp}; + +/// A queued staging action over one captured [`FileChange`]: a hunk op when `hunk_idx` is +/// `Some`, a whole-file op when `None`. The `FileChange` is cloned at enqueue time (the file +/// list is rebuilt on the next refresh), but the DIRECTION is fixed by `verb` at construction — +/// M4 uses deterministic pane-role direction (locked decision #1), not the queue's live-index +/// toggle, so there's no snapshot-staleness to resolve inside `run`. +pub struct FileStagingOp { + file: FileChange, + hunk_idx: Option, + verb: StageVerb, +} + +impl FileStagingOp { + /// A hunk-level op: applies `verb` to `file`'s hunk at `hunk_idx` (routing to the file op for + /// non-hunk-patchable statuses — [`ops::apply_hunk`]'s own fallback). + pub fn hunk(file: FileChange, hunk_idx: usize, verb: StageVerb) -> Self { + Self { + file, + hunk_idx: Some(hunk_idx), + verb, + } + } + + /// A whole-file op: applies `verb` to all of `file` via [`ops::apply_file`]. + pub fn file(file: FileChange, verb: StageVerb) -> Self { + Self { + file, + hunk_idx: None, + verb, + } + } +} + +impl StagingOp for FileStagingOp { + fn run(&mut self, ctx: &OpContext<'_>) -> Result<(), ApplyError> { + let result = match self.hunk_idx { + Some(hunk_idx) => { + ops::apply_hunk(ctx.repo, ctx.applier, &self.file, hunk_idx, self.verb) + } + None => ops::apply_file(ctx.repo, &self.file, self.verb), + }; + result.map_err(|err| map_review_error(err, &self.file.path)) + } +} + +/// Preserve `ApplyError` (so lock retry still fires); wrap any other `ReviewError` in a single +/// defensive `ApplyError::Io`. See the module doc's "error seam". +fn map_review_error(err: ReviewError, path: &str) -> ApplyError { + match err { + ReviewError::Apply(e) => e, + other => ApplyError::Io { + path: path.to_string(), + source: std::io::Error::other(other.to_string()), + }, + } +} diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index e633078..476de74 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -61,6 +61,10 @@ enum Action { CycleZoom, ToggleSplitFocus, Refresh, + StageHunk, + StageFile, + DiscardHunk, + DiscardFile, None, } @@ -94,6 +98,10 @@ fn map_key(pending: &mut Option, key: KeyEvent, pane_height: usize) -> Act KeyCode::Char('z') => Action::CycleZoom, KeyCode::Char('w') => Action::ToggleSplitFocus, KeyCode::Char('r') => Action::Refresh, + KeyCode::Char('s') => Action::StageHunk, + KeyCode::Char('S') => Action::StageFile, + KeyCode::Char('d') => Action::DiscardHunk, + KeyCode::Char('D') => Action::DiscardFile, KeyCode::Tab => Action::NextFile, KeyCode::BackTab => Action::PrevFile, KeyCode::Char(']') => { @@ -123,6 +131,10 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::CycleZoom => app.cycle_zoom(), Action::ToggleSplitFocus => app.toggle_split_focus(), Action::Refresh => app.refresh(), + Action::StageHunk => app.stage_hunk(), + Action::StageFile => app.stage_file(), + Action::DiscardHunk => app.discard_hunk(), + Action::DiscardFile => app.discard_file(), Action::None => {} } false @@ -136,8 +148,22 @@ fn apply_action(app: &mut App, action: Action) -> bool { /// notice stays visible until the user's next keystroke — that same keystroke both dismisses the /// message and performs its normal action. `Resize`/`Tick` do NOT clear it: a redraw or timer /// tick isn't the user acting on the message. +/// +/// A pending discard confirm captures the keyboard FIRST (before the notice clear and the normal +/// key map): `y` accepts, `n`/`Esc` cancels, and every other key is swallowed — a modal that +/// neither clears the notice nor runs a normal action while it's up. fn update(app: &mut App, pending: &mut Option, event: AppEvent) -> bool { match event { + AppEvent::Key(key) if app.pending_confirm.is_some() => { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => app.resolve_confirm(true), + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + app.resolve_confirm(false) + } + _ => {} + } + false + } AppEvent::Key(key) => { app.clear_notice(); apply_action(app, map_key(pending, key, app.pane_height)) @@ -458,4 +484,82 @@ mod tests { "a Resize event must not clear a notice" ); } + + #[test] + fn staging_keys_map_to_their_actions() { + let mut pending = None; + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('s')), 20), + Action::StageHunk + ); + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('S')), 20), + Action::StageFile + ); + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('d')), 20), + Action::DiscardHunk + ); + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('D')), 20), + Action::DiscardFile + ); + // Ctrl-d keeps its half-page meaning — the plain-`d` staging arm must not shadow it. + assert_eq!( + map_key(&mut pending, ctrl_key('d'), 20), + Action::MoveCursorBy(10) + ); + } + + #[test] + fn pending_confirm_captures_y_and_n_and_ignores_other_keys() { + use git_workon_fixture::prelude::*; + use workon_review::app::PendingOp; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let mut pending = None; + + // A pending confirm makes every non-answer key a no-op — the cursor doesn't move and the + // confirm stays up. + app.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); + let cursor_before = app.cursor; + update( + &mut app, + &mut pending, + AppEvent::Key(key(KeyCode::Char('j'))), + ); + assert!( + app.pending_confirm.is_some(), + "a non-answer key must not resolve the confirm" + ); + assert_eq!( + app.cursor, cursor_before, + "a captured key must not run its normal action" + ); + + // `n` cancels it. + update( + &mut app, + &mut pending, + AppEvent::Key(key(KeyCode::Char('n'))), + ); + assert!(app.pending_confirm.is_none(), "n must cancel the confirm"); + + // `y` resolves (and runs) a fresh confirm. + app.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); + update( + &mut app, + &mut pending, + AppEvent::Key(key(KeyCode::Char('y'))), + ); + assert!(app.pending_confirm.is_none(), "y must resolve the confirm"); + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::workdir_file_equals("a.txt", "one\ntwo\n")); + } } From c283eddd80fefca1f7a28e91776e76d01b883f2a Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 02:03:29 -0400 Subject: [PATCH 034/203] feat(review): stage and discard line selections --- git-workon-review/src/app.rs | 731 +++++++++++++++++++++++++++++- git-workon-review/src/ops.rs | 69 ++- git-workon-review/src/render.rs | 165 ++++++- git-workon-review/src/stage_op.rs | 43 ++ git-workon-review/src/tui.rs | 74 ++- 5 files changed, 1047 insertions(+), 35 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index ec11993..fcfa29f 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -9,7 +9,7 @@ //! handle so it can lazily read blob/worktree content per file as the user navigates to it, //! independent of whatever handle acquired the [`DiffModel`] it was built from. -use std::collections::HashMap; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::Path; use git2::Repository; @@ -18,9 +18,11 @@ use crate::acquire::{diff_uncommitted, WorktreeDiffs}; use crate::align::{align_file, collapse_gaps, inline_rows, CellKind, DisplayRow, InlineRow, Row}; use crate::apply::{Git2Applier, StageVerb}; use crate::highlight::{FgSpan, TsHighlighter}; -use crate::model::{DiffModel, FileChange, FileStatus, Hunk}; -use crate::queue::{OpOutcome, StagingQueue}; -use crate::stage_op::FileStagingOp; +use crate::model::{DiffModel, FileChange, FileStatus, Hunk, LineKind}; +use crate::ops; +use crate::queue::{OpOutcome, StagingOp, StagingQueue}; +use crate::stage_op::{FileStagingOp, LineSelectionOp}; +use crate::synthesis::LineSelection; use crate::wordiff::{word_diff_spans, Span}; /// Minimum rows kept between the cursor and the top/bottom of the pane while scrolling — see @@ -537,13 +539,24 @@ pub struct App { /// `y`/`n`/`Esc` to it and IGNORES every other key (a modal capture — see `tui::update`); the /// footer shows its prompt in place of the notice/hints. pub pending_confirm: Option, + /// The anchor row of an active line selection (`v` sets it to the current [`Self::cursor`]), + /// or `None` when no selection is active. Lives in the FOCUSED pane's active-layout coordinate + /// space, exactly like [`Self::cursor`]: the selected range is + /// `[min(anchor, cursor), max(anchor, cursor)]`, so `j`/`k` extend it for free as the cursor + /// moves. Cancelled (not translated) whenever the coordinate space reshapes — layout toggle, + /// zoom change, file switch, split-focus swap — since a raw row index carries no meaning across + /// a reshape. + pub selection_anchor: Option, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] /// (plus a hunk index for the hunk variant) rather than by a captured closure — an enum stores -/// cleanly on [`App`] and is resolved against the live diff at `y`-time. Both variants are -/// discards; line-precise discard is the next changeset (m4-select). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// cleanly on [`App`] and is resolved against the live diff at `y`-time. Every variant is a +/// discard. +/// +/// Not `Copy`: [`Self::DiscardLines`] owns a `Vec` of per-hunk [`LineSelection`]s (the selection +/// snapshot, baked in at `d`-time so the confirm survives the intervening keystrokes). +#[derive(Debug, Clone, PartialEq, Eq)] pub enum PendingOp { /// Discard one hunk of the file at `file_idx` from the worktree. `hunk_idx` indexes the /// UNSTAGED role's hunks (discard only acts in the unstaged pane), matching where the cursor @@ -551,6 +564,14 @@ pub enum PendingOp { DiscardHunk { file_idx: usize, hunk_idx: usize }, /// Discard all of the file at `file_idx`'s worktree changes. DiscardFile { file_idx: usize }, + /// Discard a line selection of the file at `file_idx` from the worktree. `selections` is one + /// `(hunk_idx, LineSelection)` per overlapped hunk, in the UNSTAGED role's coordinate space + /// (discard only acts in the unstaged pane) — the frozen keep-predicate mapping from the + /// selection active when the user pressed `d`. + DiscardLines { + file_idx: usize, + selections: Vec<(usize, LineSelection)>, + }, } /// A pending destructive op plus the scope-stating prompt shown on the footer until answered. @@ -612,6 +633,7 @@ impl App { queue: StagingQueue::new(), applier: Git2Applier, pending_confirm: None, + selection_anchor: None, } } @@ -874,6 +896,9 @@ impl App { /// raw cursor index across a role/zoom switch would be meaningless; jumping to the role's own /// first hunk (the same position a fresh file open lands on) is always valid and predictable. fn reset_panes(&mut self) { + // Any file open / zoom change reshapes the coordinate space an active selection is keyed + // in, so drop it (see [`Self::selection_anchor`]). + self.selection_anchor = None; self.split_focus = SplitPane::Unstaged; self.alt = PaneState::default(); match self.effective_zoom_for(self.current) { @@ -923,6 +948,9 @@ impl App { if self.effective_zoom_for(self.current) != EffectiveZoom::Split { return; } + // The anchor is keyed in the currently-focused pane's coordinate space; switching panes + // makes it meaningless, so drop the selection rather than carry a stale index across. + self.selection_anchor = None; std::mem::swap(&mut self.cursor, &mut self.alt.cursor); std::mem::swap(&mut self.scroll, &mut self.alt.scroll); std::mem::swap(&mut self.pane_height, &mut self.alt_height); @@ -1098,6 +1126,11 @@ impl App { Layout::Sbs => Layout::Inline, Layout::Inline => Layout::Sbs, }; + // The two layouts' row vectors are different coordinate spaces (a paired del/add is one + // SBS row but two inline rows), so a selection anchor doesn't translate — cancel it, the + // simplest defensible choice (locked decision #8's "press L for per-side precision" flow + // starts a fresh selection anyway). + self.selection_anchor = None; self.clamp_cursor(); // In a split the flip is global (both panes reflow), so the unfocused pane's cursor needs // the same clamp against ITS role's new-layout row count. Its scroll is re-derived at @@ -1164,7 +1197,15 @@ impl App { /// Stage (unstaged pane) or unstage (staged pane) the hunk under the cursor (`s`). Refuses on /// the combined view, or when the cursor isn't in a hunk. + /// + /// When a line selection is active, `s` acts on the SELECTION instead + /// ([`Self::stage_selection`]) — the hunk under the cursor is irrelevant once the user has + /// marked exact lines. pub fn stage_hunk(&mut self) { + if self.selection_anchor.is_some() { + self.stage_selection(); + return; + } if self.files.is_empty() { return; } @@ -1215,7 +1256,14 @@ impl App { /// Request confirmation to discard the hunk under the cursor from the worktree (`d`). Refuses /// on the combined view, in a staged pane (discard only reverts worktree changes), or when the /// cursor isn't in a hunk. The discard itself runs when the user answers `y`. + /// + /// When a line selection is active, `d` acts on the SELECTION instead + /// ([`Self::discard_selection`]). pub fn discard_hunk(&mut self) { + if self.selection_anchor.is_some() { + self.discard_selection(); + return; + } if self.files.is_empty() { return; } @@ -1284,6 +1332,10 @@ impl App { let Some(confirm) = self.pending_confirm.take() else { return; }; + // Answering the modal consumes any active selection either way: its snapshot is already + // baked into the `PendingOp` for a line discard, and a lingering highlight after the modal + // closes would be confusing. A no-op when nothing is selected. + self.selection_anchor = None; if !accept { return; } @@ -1302,15 +1354,31 @@ impl App { }; self.run_op(FileStagingOp::file(file, StageVerb::Discard)); } + PendingOp::DiscardLines { + file_idx, + selections, + } => { + // Line discard acts in the unstaged pane, so its selections are keyed against the + // unstaged role's sub-`FileChange`. + let Some(file) = self.role_change(file_idx, Role::Unstaged).cloned() else { + return; + }; + self.run_op(LineSelectionOp::new(file, selections, StageVerb::Discard)); + } } } - /// Enqueue `op`, drain the queue on the same beat, then act on the outcomes: any failure or - /// panic surfaces on the footer and does NOT refresh (the index didn't change as intended); an - /// all-`Completed` drain refreshes once, rebuilding the views + attribution from the new index - /// (locked decision #5). Only ever one op is in flight, so the queue's trap-4 staleness can't - /// arise — the queue is here for its lock-retry and panic isolation. - fn run_op(&mut self, op: FileStagingOp) { + /// Enqueue `op`, drain the queue on the same beat, then act on the outcome: a failure or panic + /// surfaces on the footer and skips the refresh (the index is now in whatever partial state + /// the failed op left it in — the user resolves with `r`); a `Completed` drain refreshes, + /// rebuilding the views + attribution from the new index (locked decision #5). + /// + /// Generic over any [`StagingOp`] — a hunk/file op ([`FileStagingOp`]) or a (possibly + /// multi-hunk) line selection ([`LineSelectionOp`], which applies as ONE merged patch rather + /// than enqueueing one op per hunk — see that type's docs for why splitting is wrong). Either + /// way exactly one op is ever in flight, so the queue's trap-4 live-index staleness doesn't + /// apply — the queue is here for its lock-retry and panic isolation. + fn run_op(&mut self, op: impl StagingOp + 'static) { self.queue.enqueue(op); // Distinct fields (`queue` mutable, `repo`/`applier` shared) — the borrow checker permits // the disjoint borrows in one call, so the queue needn't be taken out and put back. @@ -1325,6 +1393,244 @@ impl App { None => self.refresh(), } } + + /// Start a line selection anchored at the current cursor (`v`). Refuses (a notice, no anchor + /// set) on the combined view or any non-staging role — you can only select lines where you can + /// stage them (same gate as the verbs). A no-op on an empty file list. + pub fn start_selection(&mut self) { + if self.files.is_empty() { + return; + } + if self.staging_role().is_none() { + self.notify( + "select in the unstaged/staged pane — cycle zoom (z)", + Severity::Error, + ); + return; + } + self.selection_anchor = Some(self.cursor); + } + + /// Cancel an active line selection (`Esc`). A no-op when none is active. + pub fn cancel_selection(&mut self) { + self.selection_anchor = None; + } + + /// The inclusive `[lo, hi]` row range of the active selection in the focused pane's active + /// layout coordinate space, or `None` when no selection is active. Derived fresh from + /// anchor+cursor so `j`/`k` extend it for free. + pub fn selection_range(&self) -> Option<(usize, usize)> { + let anchor = self.selection_anchor?; + Some((anchor.min(self.cursor), anchor.max(self.cursor))) + } + + /// Map the active selection to one [`LineSelection`] per hunk it overlaps — locked decision + /// #8's keep-predicate mapping. Returns `(hunk_idx, LineSelection)` per hunk, ascending by + /// `hunk_idx`, keeping only hunks with at least one changed line inside the range (a hunk + /// grazed by only context/gap rows is dropped). Empty when there's no selection, no loaded + /// focused view, or the range covers only context. + /// + /// The two layouts differ in what a selected row contributes (locked decision #8): + /// - **SBS** row-pair semantics: a selected `AlignedRow` keeps BOTH sides it changes — its Del + /// cell's old line and its Add cell's new line — because a side-by-side row can't split a + /// paired edit (per-side precision is what inline is for). + /// - **Inline**: a selected `Del` keeps only its old-side line, an `Add` only its new-side + /// line. + /// + /// A [`LineSelection`]'s keys are indices into the hunk's `lines` vec, not line numbers, so + /// the collected old-del / new-add line numbers are resolved back to `HunkLine` positions via + /// the role's own [`FileChange`] — see [`line_selection_for_hunk`]. + fn selection_line_ops(&self) -> Vec<(usize, LineSelection)> { + let Some((lo, hi)) = self.selection_range() else { + return Vec::new(); + }; + let Some(role) = self.staging_role() else { + return Vec::new(); + }; + let Some(view) = self.current_view_ref() else { + return Vec::new(); + }; + let Some(file) = self.role_change(self.current, role) else { + return Vec::new(); + }; + + // Per hunk: (selected old-side deletion linenos, selected new-side addition linenos). + let mut per_hunk: BTreeMap, BTreeSet)> = BTreeMap::new(); + match self.layout { + Layout::Sbs => { + for r in lo..=hi { + let Some(DisplayRow::Row(row)) = view.display.get(r) else { + continue; + }; + let Some(hunk_idx) = view.hunk_at_display_row(r) else { + continue; + }; + let entry = per_hunk.entry(hunk_idx).or_default(); + if row.old_kind == CellKind::Del { + if let Row::Line(n) = row.old { + entry.0.insert(n as u32); + } + } + if row.new_kind == CellKind::Add { + if let Row::Line(n) = row.new { + entry.1.insert(n as u32); + } + } + } + } + Layout::Inline => { + for r in lo..=hi { + let Some(row) = view.inline.get(r) else { + continue; + }; + let Some(hunk_idx) = view.hunk_at_inline_row(r) else { + continue; + }; + match *row { + InlineRow::Del { old, .. } => { + per_hunk.entry(hunk_idx).or_default().0.insert(old as u32); + } + InlineRow::Add { new, .. } => { + per_hunk.entry(hunk_idx).or_default().1.insert(new as u32); + } + InlineRow::Context { .. } | InlineRow::Gap { .. } => {} + } + } + } + } + + per_hunk + .into_iter() + .filter_map(|(hunk_idx, (dels, adds))| { + let hunk = file.hunks.get(hunk_idx)?; + let sel = line_selection_for_hunk(hunk, &dels, &adds); + if sel.keep_dels.is_empty() && sel.keep_adds.is_empty() { + None + } else { + Some((hunk_idx, sel)) + } + }) + .collect() + } + + /// 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. + fn stage_selection(&mut self) { + if self.files.is_empty() { + self.cancel_selection(); + return; + } + let Some(role) = self.staging_role() else { + self.notify( + "stage in the unstaged/staged pane — cycle zoom (z)", + Severity::Error, + ); + return; + }; + let Some(verb) = Self::verb_for_role(role) else { + return; + }; + if !ops::is_hunk_patchable(&self.files[self.current]) { + self.notify( + "line staging needs a modified file — use s/S for the whole file", + Severity::Error, + ); + return; + } + let selections = self.selection_line_ops(); + if selections.is_empty() { + self.notify("no changed lines in selection", Severity::Error); + return; + } + let Some(file) = self.role_change(self.current, role).cloned() else { + return; + }; + self.run_op(LineSelectionOp::new(file, selections, verb)); + self.cancel_selection(); + } + + /// 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`. + fn discard_selection(&mut self) { + if self.files.is_empty() { + self.cancel_selection(); + return; + } + let Some(role) = self.staging_role() else { + self.notify( + "stage in the unstaged/staged pane — cycle zoom (z)", + Severity::Error, + ); + return; + }; + if role != Role::Unstaged { + self.notify("discard acts in the unstaged pane", Severity::Error); + return; + } + if !ops::is_hunk_patchable(&self.files[self.current]) { + self.notify( + "line staging needs a modified file — use s/S for the whole file", + Severity::Error, + ); + return; + } + let selections = self.selection_line_ops(); + if selections.is_empty() { + self.notify("no changed lines in selection", Severity::Error); + return; + } + let total: usize = selections + .iter() + .map(|(_, s)| s.keep_dels.len() + s.keep_adds.len()) + .sum(); + let hunks = selections.len(); + let prompt = format!( + "Discard {total} line{} across {hunks} hunk{} from the worktree? (y/n)", + if total == 1 { "" } else { "s" }, + if hunks == 1 { "" } else { "s" }, + ); + self.request_confirm( + prompt, + PendingOp::DiscardLines { + file_idx: self.current, + selections, + }, + ); + } +} + +/// 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 +/// `new_lnum` is in `keep_new_adds`; context lines contribute nothing. +fn line_selection_for_hunk( + hunk: &Hunk, + keep_old_dels: &BTreeSet, + keep_new_adds: &BTreeSet, +) -> LineSelection { + let mut sel = LineSelection::default(); + for (i, line) in hunk.lines.iter().enumerate() { + match line.kind { + LineKind::Deletion => { + if line.old_lnum.is_some_and(|o| keep_old_dels.contains(&o)) { + sel.keep_dels.insert(i); + } + } + LineKind::Addition => { + if line.new_lnum.is_some_and(|n| keep_new_adds.contains(&n)) { + sel.keep_adds.insert(i); + } + } + LineKind::Context => {} + } + } + sel } /// The diff-derived pieces [`App::new`] and [`App::refresh`] both build fresh from a @@ -2803,4 +3109,403 @@ mod tests { "a refused stage must not touch the index" ); } + + // ---- M4 line selection ----------------------------------------------------------------- + + /// One hunk with two independent paired changes (line 2 `b`->`B`, line 4 `d`->`D`, one + /// context line `c` between them). SBS display rows: 0 ctx, 1 del/add (b/B), 2 ctx, 3 del/add + /// (d/D), 4 ctx — so `open_current` lands the cursor on row 1 (the first change). + fn two_changes_one_hunk_fixture() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "a\nb\nc\nd\ne\n", "a\nB\nc\nD\ne\n") + .build() + .unwrap() + } + + #[test] + fn line_selection_for_hunk_resolves_linenos_to_hunk_line_indices() { + use super::line_selection_for_hunk; + use crate::model::{Hunk, HunkLine, LineKind}; + use std::collections::BTreeSet; + + let line = |kind, content: &str, old, new| HunkLine { + kind, + content: content.as_bytes().to_vec(), + old_lnum: old, + new_lnum: new, + missing_newline: false, + }; + let hunk = Hunk { + old_start: 1, + old_count: 5, + new_start: 1, + new_count: 5, + header: b"@@ -1,5 +1,5 @@\n".to_vec(), + lines: vec![ + line(LineKind::Context, "a\n", Some(1), Some(1)), + line(LineKind::Deletion, "b\n", Some(2), None), + line(LineKind::Addition, "B\n", None, Some(2)), + line(LineKind::Deletion, "d\n", Some(4), None), + line(LineKind::Addition, "D\n", None, Some(4)), + ], + }; + // Keep the b->B change (old-del line 2, new-add line 2), drop the line-4 change: the keep + // sets are HUNK-LINE INDICES (1 = the `b` deletion, 2 = the `B` addition), not linenos. + let sel = line_selection_for_hunk(&hunk, &BTreeSet::from([2]), &BTreeSet::from([2])); + assert_eq!(sel.keep_dels, BTreeSet::from([1])); + assert_eq!(sel.keep_adds, BTreeSet::from([2])); + } + + #[test] + fn sbs_selection_of_a_paired_row_keeps_both_its_del_and_add() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); // cursor lands on row 1 (the b->B paired change) + app.selection_anchor = Some(app.cursor); // single-row selection + + let ops = app.selection_line_ops(); + assert_eq!(ops.len(), 1, "one hunk overlapped"); + let (hunk_idx, sel) = &ops[0]; + assert_eq!(*hunk_idx, 0); + // SBS row-pair semantics (locked decision #8): a paired row keeps BOTH sides. + assert_eq!(sel.keep_dels.len(), 1, "SBS keeps the row's deleted line"); + assert_eq!(sel.keep_adds.len(), 1, "SBS keeps the row's added line too"); + } + + #[test] + fn inline_selection_of_a_del_row_keeps_only_the_del() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.toggle_layout(); // -> inline (a paired change becomes a Del row then an Add row) + + let del_row = app + .current_view_ref() + .unwrap() + .inline + .iter() + .position(|r| matches!(r, InlineRow::Del { old: 2, .. })) + .expect("the b-deletion has its own inline Del row"); + app.cursor = del_row; + app.selection_anchor = Some(del_row); + + let ops = app.selection_line_ops(); + assert_eq!(ops.len(), 1); + let (_, sel) = &ops[0]; + // Inline keeps exactly the one side the selected row shows (locked decision #8). + assert_eq!( + sel.keep_dels.len(), + 1, + "the selected Del contributes its del" + ); + assert_eq!(sel.keep_adds.len(), 0, "and NOT its paired add"); + } + + #[test] + fn multi_hunk_selection_splits_into_one_line_selection_per_hunk() { + let fixture = two_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + // Anchor at the top, sweep the cursor to the last row: the range spans both hunks. + app.cursor = 0; + app.selection_anchor = Some(0); + app.cursor = app.current_view_ref().unwrap().display.len() - 1; + + let ops = app.selection_line_ops(); + assert_eq!(ops.len(), 2, "two overlapped hunks -> two line selections"); + assert_eq!(ops[0].0, 0, "ascending hunk order"); + assert_eq!(ops[1].0, 1); + assert!( + ops.iter() + .all(|(_, s)| !s.keep_dels.is_empty() || !s.keep_adds.is_empty()), + "each entry carries at least one changed line" + ); + } + + #[test] + fn context_only_selection_yields_no_line_ops() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + // Row 0 is the leading context line `a`. + app.cursor = 0; + app.selection_anchor = Some(0); + assert!( + app.selection_line_ops().is_empty(), + "a context-only selection maps to no line ops" + ); + } + + #[test] + fn stage_selection_stages_only_the_selected_lines_of_a_multi_change_hunk() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); // cursor on the first change row (b->B) + app.start_selection(); // anchor there -> single-row selection + app.stage_hunk(); // routes to the selection stage + + assert!( + app.notice.is_none(), + "a clean line stage sets no error notice" + ); + assert!( + app.selection_anchor.is_none(), + "the selection clears after applying" + ); + let repo = fixture.repo().unwrap(); + // ONLY the b->B change landed in the index; d->D is still just in the worktree. + repo.assert(predicate::repo::index_blob_equals( + "f.txt", + "a\nB\nc\nd\ne\n", + )); + repo.assert(predicate::repo::workdir_file_equals( + "f.txt", + "a\nB\nc\nD\ne\n", + )); + repo.assert(predicate::repo::has_unstaged_file("f.txt")); + } + + #[test] + fn discard_selection_confirm_states_scope_then_y_reverts_only_selected_lines() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.start_selection(); // first change (b->B) only + app.discard_hunk(); // routes to the selection discard + + let confirm = app + .pending_confirm + .as_ref() + .expect("line discard requests a confirm"); + // True-scope message: 1 del + 1 add kept = 2 lines, in 1 hunk. + assert!( + confirm.prompt.contains("Discard 2 lines across 1 hunk"), + "got: {:?}", + confirm.prompt + ); + // Nothing reverted until `y`. + fixture.assert(predicate::repo::workdir_file_equals( + "f.txt", + "a\nB\nc\nD\ne\n", + )); + + app.resolve_confirm(true); + assert!( + app.selection_anchor.is_none(), + "answering clears the selection" + ); + // Only the selected b->B change reverted in the worktree; d->D stays. + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::workdir_file_equals( + "f.txt", + "a\nb\nc\nD\ne\n", + )); + } + + #[test] + fn discard_selection_confirm_n_leaves_the_worktree_unchanged() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.start_selection(); + app.discard_hunk(); + app.resolve_confirm(false); + + assert!(app.pending_confirm.is_none(), "n clears the confirm"); + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::workdir_file_equals( + "f.txt", + "a\nB\nc\nD\ne\n", + )); + } + + /// Two well-separated hunks in one file. The FIRST (earlier) hunk is a net +2 lines (a + /// modification plus two insertions), so its change shifts every later line number in + /// whichever text already contains it. A per-hunk patch is synthesized against the FULL + /// unstaged (index ↔ worktree) diff, so hunk 2's header numbers already assume hunk 1's +2 + /// shift is present — draining hunk 1 and hunk 2 as two SEPARATE applies against the index + /// (which starts with neither hunk's change) leaves hunk 2's patch inconsistent with + /// whatever the index actually contains at that point, and libgit2 (strict, no fuzz) rejects + /// it regardless of which hunk goes first. Merging both hunks into ONE patch (ascending + /// order, exactly as they appear in the source diff) keeps the numbering internally + /// consistent, so the whole selection applies in a single shot — see + /// [`crate::ops::apply_line_selections`]. + fn two_hunks_net_shift_fixture() -> Fixture { + let committed: String = (1..=20).map(|n| format!("L{n}\n")).collect(); + let mut worktree = String::from("L1\nL2X\nINS_A\nINS_B\n"); + for n in 3..=17 { + worktree.push_str(&format!("L{n}\n")); + } + worktree.push_str("L18X\nL19\nL20\n"); + + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", &committed, &worktree) + .build() + .unwrap() + } + + #[test] + fn line_stage_across_two_hunks_applies_both_despite_the_earlier_hunks_line_shift() { + let fixture = two_hunks_net_shift_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + // Select the whole file (both hunks and the gap between them), then stage the selection. + app.cursor = 0; + app.start_selection(); + app.scroll_bottom(); // cursor -> last display row + app.stage_hunk(); // active selection -> stage_selection over both hunks + + assert!( + app.notice.is_none(), + "both hunks must stage cleanly; got notice: {:?}", + app.notice + ); + assert!( + app.selection_anchor.is_none(), + "selection clears after apply" + ); + // Both changes landed in the index in ONE apply: it now equals the worktree in full. + let repo = fixture.repo().unwrap(); + let worktree = std::fs::read_to_string(repo.workdir().unwrap().join("f.txt")).unwrap(); + repo.assert(predicate::repo::index_blob_equals( + "f.txt", + worktree.as_str(), + )); + } + + #[test] + fn line_discard_across_two_hunks_reverts_both_despite_the_earlier_hunks_line_shift() { + // Mirrors the stage tripwire above for the DISCARD verb (worktree -> index, reversed): a + // multi-hunk line discard must also merge into one patch rather than one apply per hunk. + let fixture = two_hunks_net_shift_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + app.cursor = 0; + app.start_selection(); + app.scroll_bottom(); + app.discard_hunk(); // active selection -> discard_selection over both hunks + + let confirm = app + .pending_confirm + .as_ref() + .expect("multi-hunk line discard requests a confirm"); + assert!( + confirm.prompt.contains("hunks"), + "expected the true-scope prompt to name multiple hunks, got: {:?}", + confirm.prompt + ); + + app.resolve_confirm(true); + assert!( + app.notice.is_none(), + "both hunks must discard cleanly in one apply; got notice: {:?}", + app.notice + ); + assert!( + app.selection_anchor.is_none(), + "selection clears after resolving the confirm" + ); + + // Both changes reverted in the worktree in ONE apply: it now equals HEAD in full. + let repo = fixture.repo().unwrap(); + let head: String = (1..=20).map(|n| format!("L{n}\n")).collect(); + repo.assert(predicate::repo::workdir_file_equals("f.txt", head.as_str())); + } + + #[test] + fn line_stage_on_untracked_file_refuses_with_modified_file_message() { + use super::Severity; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "x\ny\nz\n") + .build() + .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.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"), + "got: {:?}", + notice.text + ); + let repo = fixture.repo().unwrap(); + assert!( + !predicate::repo::has_staged_file("new.txt").eval(repo), + "a refused line stage must not touch the index" + ); + } + + #[test] + fn line_stage_of_context_only_selection_refuses() { + use super::Severity; + + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.cursor = 0; // leading context row + app.start_selection(); + app.stage_hunk(); + + let notice = app.notice.as_ref().expect("context-only stage must refuse"); + assert_eq!(notice.severity, Severity::Error); + assert!( + notice.text.contains("no changed lines in selection"), + "got: {:?}", + notice.text + ); + let repo = fixture.repo().unwrap(); + assert!(!predicate::repo::has_staged_file("f.txt").eval(repo)); + } + + #[test] + fn start_selection_in_combined_view_refuses() { + use super::{Severity, Zoom}; + + let fixture = partial_fixture(); + let mut app = app_from_fixture(&fixture); + app.zoom = Zoom::Combined; + app.open_current(); + app.start_selection(); + + assert!( + app.selection_anchor.is_none(), + "the combined view has no staging direction, so selection is refused" + ); + let notice = app.notice.as_ref().expect("combined selection must refuse"); + assert_eq!(notice.severity, Severity::Error); + assert!(notice.text.contains("cycle zoom"), "got: {:?}", notice.text); + } + + #[test] + fn cancel_and_layout_toggle_both_clear_an_active_selection() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + app.start_selection(); + assert!(app.selection_anchor.is_some()); + app.cancel_selection(); + assert!( + app.selection_anchor.is_none(), + "cancel clears the selection" + ); + + // A layout toggle reshapes the coordinate space, so it also cancels. + app.start_selection(); + assert!(app.selection_anchor.is_some()); + app.toggle_layout(); + assert!( + app.selection_anchor.is_none(), + "toggling layout cancels the selection" + ); + } } diff --git a/git-workon-review/src/ops.rs b/git-workon-review/src/ops.rs index 60d952e..3f22632 100644 --- a/git-workon-review/src/ops.rs +++ b/git-workon-review/src/ops.rs @@ -1,7 +1,8 @@ //! Routing: the ONE place (per the M2 design decision) that decides, for a given //! [`FileChange`], whether a staging verb goes through the patch-synthesis-and-apply path //! (`synthesis.rs`/`apply.rs`) or the whole-file path (`file_ops.rs`). The TUI (M4) calls only -//! these three functions — it never picks a path itself. +//! [`apply_hunk`]/[`apply_lines`]/[`apply_line_selections`]/[`apply_file`] — it never picks a +//! path itself. //! //! ## The routing table (trap 3) //! @@ -22,15 +23,20 @@ use git2::Repository; use crate::apply::{Applier, StageVerb}; -use crate::error::ReviewError; +use crate::error::{ReviewError, SynthesisError}; use crate::file_ops; use crate::model::{FileChange, FileStatus}; -use crate::synthesis::{partial_hunk_patch, whole_hunk_patch, LineSelection}; +use crate::synthesis::{partial_hunk_patch, whole_hunk_patch, LineSelection, PatchText}; /// Whether `file`'s status/binary-ness can be expressed as a two-sided hunk patch (Modified, /// Renamed, or Copied, and not binary) — the routing predicate shared by `apply_hunk` and the /// doc comments above. -fn is_hunk_patchable(file: &FileChange) -> bool { +/// +/// Also the **line-op eligibility predicate** the TUI pre-validates against before offering +/// line selection: `apply_lines` REFUSES a non-hunk-patchable file (trap 3, no silent widening), +/// so the TUI checks this first and shows a "use the whole-file op" notice instead of enqueuing a +/// doomed line op. +pub fn is_hunk_patchable(file: &FileChange) -> bool { !file.is_binary && matches!( file.status, @@ -68,6 +74,10 @@ pub fn apply_hunk( /// ([`crate::error::SynthesisError::LineSelectionUnsupported`] / /// [`crate::error::SynthesisError::BinaryFile`]), so calling it unconditionally and propagating /// its `Result` is both the simplest routing and the correct one. +/// +/// Single-hunk only — see [`apply_line_selections`] for a selection spanning multiple hunks, +/// which must NOT be applied as N separate calls to this function (trap 7, see that function's +/// docs). pub fn apply_lines( repo: &Repository, applier: &dyn Applier, @@ -82,6 +92,57 @@ pub fn apply_lines( Ok(()) } +/// Apply `verb` to a line-precise selection spanning POSSIBLY MULTIPLE hunks of `file`, as ONE +/// combined patch (trap 7). +/// +/// Each `(hunk_idx, LineSelection)` synthesizes its own single-hunk [`PatchText`] via +/// [`partial_hunk_patch`] (same refusals as [`apply_lines`]: propagated from the FIRST hunk that +/// fails to synthesize — binary/unsupported-status/out-of-range/empty-selection). Their `.hunks` +/// are then merged, in ASCENDING `hunk_idx` order, into a single [`PatchText`] sharing the file's +/// paths/modes (all per-hunk patches synthesize the same ones, since they're all from the same +/// `file`) — and applied ONCE. +/// +/// This is NOT equivalent to calling [`apply_lines`] once per hunk: each per-hunk patch is +/// synthesized from the unstaged (index ↔ worktree) diff, so its hunk header's line numbers +/// already assume every OTHER selected hunk's change is present too (the worktree has them all). +/// Applying any single hunk's patch standalone against the index — which has none of the other +/// hunks yet — leaves the index's actual line numbering inconsistent with what that hunk's header +/// declares, and libgit2 (strict, no fuzz) rejects the apply. Merging first keeps every selected +/// hunk's numbering internally consistent within the one patch, exactly as it is in the source +/// diff, so the whole thing applies cleanly in one shot. +/// +/// `selections` empty (or every hunk's own selection resolving empty) is the caller's +/// responsibility to have already refused before enqueuing — this returns +/// [`SynthesisError::EmptySelection`] as a defensive guard rather than silently no-opping. +pub fn apply_line_selections( + repo: &Repository, + applier: &dyn Applier, + file: &FileChange, + selections: &[(usize, LineSelection)], + verb: StageVerb, +) -> Result<(), ReviewError> { + let (base, dest, dir) = verb.plan(); + + let mut ordered: Vec<&(usize, LineSelection)> = selections.iter().collect(); + ordered.sort_by_key(|(hunk_idx, _)| *hunk_idx); + + let mut merged: Option = None; + for (hunk_idx, sel) in ordered { + let patch = partial_hunk_patch(file, *hunk_idx, sel, base)?; + match &mut merged { + None => merged = Some(patch), + Some(existing) => existing.hunks.extend(patch.hunks), + } + } + let merged = merged.ok_or_else(|| SynthesisError::EmptySelection { + path: file.path.clone(), + hunk: 0, + })?; + + applier.apply(repo, &merged, dest, dir)?; + Ok(()) +} + /// Apply `verb` to the WHOLE of `file`, unconditionally via `file_ops.rs` — no synthesis /// involved. This is also `apply_hunk`'s fallback for statuses a hunk patch can't express. /// diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 5aa38f8..435884a 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -41,6 +41,11 @@ const FG_GUTTER: Color = Color::DarkGray; /// Tint blended into the cursor row's background (see [`blend_bg`]) — a cool slate-blue, chosen /// to read as "cursor here" without competing with the warm del/add hues above. const BG_CURSOR: Color = Color::Rgb(45, 50, 90); +/// Tint blended into a SELECTED row's background (line selection, `v`) — a muted teal, distinct +/// from [`BG_CURSOR`]'s slate-blue so a selected-but-not-cursor row reads apart from the cursor +/// row. The cursor row inside a selection keeps the cursor tint (cursor wins on its own row — see +/// [`render_pane_sbs`]). +const BG_SELECTION: Color = Color::Rgb(30, 66, 66); /// Blend the cursor row's tint into an existing background, so the cursor highlight composites /// with (rather than replaces) del/add/word-diff emphasis on the same row — the row highlight is @@ -61,25 +66,36 @@ fn blend_bg(base: Option, tint: Color) -> Color { } } -/// Apply the cursor row's highlight to an already-built line: blend [`BG_CURSOR`] into every -/// span's background (see [`blend_bg`]), then pad the line out to `width` with solid tint so the -/// highlight covers the full row even past the line's own rendered content (a short line, or one -/// pane of a filler/deleted-file row, would otherwise leave the tail of the row unhighlighted). -fn apply_cursor_row(mut line: Line<'static>, width: u16) -> Line<'static> { +/// Blend `tint` into an already-built line's background: mix it into every span's bg (see +/// [`blend_bg`]), then pad out to `width` with solid tint so the highlight covers the full row even +/// past the line's own rendered content (a short line, or one pane of a filler/deleted-file row, +/// would otherwise leave the tail of the row unhighlighted). Shared by the cursor and selection +/// row washes — they differ only in the tint color. +fn apply_row_tint(mut line: Line<'static>, width: u16, tint: Color) -> Line<'static> { for span in &mut line.spans { - let bg = blend_bg(span.style.bg, BG_CURSOR); + let bg = blend_bg(span.style.bg, tint); span.style = span.style.bg(bg); } let used = line.width() as u16; if used < width { line.spans.push(TSpan::styled( " ".repeat((width - used) as usize), - Style::default().bg(BG_CURSOR), + Style::default().bg(tint), )); } line } +/// Wash the cursor row with [`BG_CURSOR`]. +fn apply_cursor_row(line: Line<'static>, width: u16) -> Line<'static> { + apply_row_tint(line, width, BG_CURSOR) +} + +/// Wash a selected (line-selection) row with [`BG_SELECTION`]. +fn apply_selection_row(line: Line<'static>, width: u16) -> Line<'static> { + apply_row_tint(line, width, BG_SELECTION) +} + /// One resolved (bg, fg) pair for a byte range of a line. struct Segment { start: usize, @@ -374,7 +390,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ); } None => { - let text = "j/k scroll s/S stage d/D discard z zoom w focus r refresh q quit"; + let text = "j/k scroll v select s/S stage d/D discard z zoom w focus q quit"; frame.render_widget( Paragraph::new(text).style(Style::default().fg(FG_DIM)), area, @@ -386,11 +402,21 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { /// Write a gap row's `··· N unchanged lines ···` 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. -fn render_gap_row(buf: &mut Buffer, area: Rect, y: u16, skipped: usize, is_cursor: bool) { +fn render_gap_row( + buf: &mut Buffer, + area: Rect, + y: u16, + skipped: usize, + is_cursor: bool, + is_selected: bool, +) { let msg = format!("··· {skipped} unchanged lines ···"); let line = Line::from(TSpan::styled(msg, Style::default().fg(FG_DIM))); + // Cursor wins over selection on the same row. let line = if is_cursor { apply_cursor_row(line, area.width) + } else if is_selected { + apply_selection_row(line, area.width) } else { line }; @@ -419,10 +445,14 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect) { app.pane_height = area.height as usize; let scroll = app.scroll; let cursor = Some(app.cursor); + // The single pane is the focused one, so it shows any active selection. + let selection = app.selection_range(); match app.layout { - AppLayout::Sbs => render_pane_sbs(frame, app, area, idx, role, scroll, cursor), + AppLayout::Sbs => { + render_pane_sbs(frame, app, area, idx, role, scroll, cursor, selection) + } AppLayout::Inline => { - render_pane_inline(frame, app, area, idx, role, scroll, cursor) + render_pane_inline(frame, app, area, idx, role, scroll, cursor, selection) } } } @@ -442,9 +472,14 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { let role = app.split_focus_role(); app.pane_height = area.height as usize; let (scroll, cursor) = app.pane_render_state(role); + let selection = app.selection_range(); match app.layout { - AppLayout::Sbs => render_pane_sbs(frame, app, area, idx, role, scroll, cursor), - AppLayout::Inline => render_pane_inline(frame, app, area, idx, role, scroll, cursor), + AppLayout::Sbs => { + render_pane_sbs(frame, app, area, idx, role, scroll, cursor, selection) + } + AppLayout::Inline => { + render_pane_inline(frame, app, area, idx, role, scroll, cursor, selection) + } } return; } @@ -475,6 +510,11 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { 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); match app.layout { AppLayout::Sbs => { render_pane_sbs( @@ -485,6 +525,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { Role::Unstaged, u_scroll, u_cursor, + u_selection, ); render_pane_sbs( frame, @@ -494,6 +535,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { Role::Staged, s_scroll, s_cursor, + s_selection, ); } AppLayout::Inline => { @@ -505,6 +547,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { Role::Unstaged, u_scroll, u_cursor, + u_selection, ); render_pane_inline( frame, @@ -514,6 +557,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { Role::Staged, s_scroll, s_cursor, + s_selection, ); } } @@ -539,6 +583,7 @@ fn render_pane_sbs( role: Role, scroll: usize, cursor: Option, + selection: Option<(usize, usize)>, ) { let left_w = area.width.saturating_sub(1) / 2; let right_w = area.width.saturating_sub(1).saturating_sub(left_w); @@ -596,9 +641,17 @@ fn render_pane_sbs( for (i, row_idx) in (scroll..end).enumerate() { let y = area.y + i as u16; let is_cursor = cursor == Some(row_idx); + let is_selected = selection.is_some_and(|(lo, hi)| row_idx >= lo && row_idx <= hi); match &view.display[row_idx] { DisplayRow::Gap { skipped } => { - render_gap_row(frame.buffer_mut(), area, y, *skipped, is_cursor); + render_gap_row( + frame.buffer_mut(), + area, + y, + *skipped, + is_cursor, + is_selected, + ); } DisplayRow::Row(row) => { let is_pair = row.is_word_diff_pair(); @@ -630,11 +683,17 @@ fn render_pane_sbs( new_gutter_w, new_area.width as usize, ); + // Cursor wins over selection on the same row (see [`BG_SELECTION`]). let (old_line, new_line) = if is_cursor { ( apply_cursor_row(old_line, old_area.width), apply_cursor_row(new_line, new_area.width), ) + } else if is_selected { + ( + apply_selection_row(old_line, old_area.width), + apply_selection_row(new_line, new_area.width), + ) } else { (old_line, new_line) }; @@ -733,6 +792,7 @@ fn build_inline_line( /// Render one inline pane of `role`'s view for file `idx` into `area`, scrolled to `scroll`. See /// [`render_pane_sbs`] for the `cursor`/highlight contract; this is its inline-coordinate-space /// analog. +#[allow(clippy::too_many_arguments)] fn render_pane_inline( frame: &mut Frame, app: &mut App, @@ -741,6 +801,7 @@ fn render_pane_inline( role: Role, scroll: usize, cursor: Option, + selection: Option<(usize, usize)>, ) { let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), area); @@ -771,9 +832,17 @@ fn render_pane_inline( for (i, row_idx) in (scroll..end).enumerate() { let y = area.y + i as u16; let is_cursor = cursor == Some(row_idx); + let is_selected = selection.is_some_and(|(lo, hi)| row_idx >= lo && row_idx <= hi); match &view.inline[row_idx] { InlineRow::Gap { skipped } => { - render_gap_row(frame.buffer_mut(), area, y, *skipped, is_cursor); + render_gap_row( + frame.buffer_mut(), + area, + y, + *skipped, + is_cursor, + is_selected, + ); } row => { let (old_spans, new_spans) = if row.is_word_diff_pair() { @@ -788,8 +857,11 @@ fn render_pane_inline( }; let line = build_inline_line(view, row, word_spans, mode, old_gutter_w, new_gutter_w); + // Cursor wins over selection on the same row (see [`BG_SELECTION`]). let line = if is_cursor { apply_cursor_row(line, area.width) + } else if is_selected { + apply_selection_row(line, area.width) } else { line }; @@ -1137,6 +1209,69 @@ mod tests { ); } + #[test] + fn selected_rows_carry_the_selection_tint_distinct_from_cursor_and_plain_rows() { + // Anchor at l10 and put the cursor at l12, so the selection covers l10..=l12. The cursor + // (always one endpoint of the range) sits on l12 and wins the wash there; l10 and l11 are + // selected-but-not-cursor, showing the pure selection tint over plain context. + 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) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + + let row_for = |app: &mut App, lineno: usize| { + app.current_view_ref() + .unwrap() + .display + .iter() + .position(|row| matches!(row, DisplayRow::Row(r) if r.old == Row::Line(lineno))) + .unwrap_or_else(|| panic!("l{lineno} row present")) + }; + let l10 = row_for(&mut app, 10); + let l12 = row_for(&mut app, 12); + + app.selection_anchor = Some(l10); + app.cursor = l12; + assert_eq!(app.selection_range(), Some((l10, l12))); + + let buf = render_once(&mut app, 60, 20); + let content = buf_lines(&buf); + let y_of = |needle: &str| { + content + .iter() + .position(|line| line.contains(needle)) + .unwrap_or_else(|| panic!("{needle} visible")) as u16 + }; + let sel_y = y_of("l10 "); // selected, not cursor + let other_y = y_of("l11 "); // selected, not cursor — same tint as l10 + let cursor_y = y_of("l12 "); // cursor endpoint of the range — cursor tint wins here + + let bg = |x: u16, y: u16| buf.cell((x, y)).unwrap().style().bg; + assert_eq!( + bg(1, sel_y), + bg(1, other_y), + "both selected rows carry the same selection tint" + ); + assert_ne!( + bg(1, sel_y), + bg(1, cursor_y), + "the selection tint must differ from the cursor row's tint" + ); + // And the selection tint is specifically BG_SELECTION blended over plain context (which + // has no bg) — i.e. the raw tint, since blend_bg(None, tint) == tint. + assert_eq!( + bg(1, sel_y), + Some(super::BG_SELECTION), + "a selected plain-context row shows the raw selection tint" + ); + } + #[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 diff --git a/git-workon-review/src/stage_op.rs b/git-workon-review/src/stage_op.rs index 561f636..dc0d1d1 100644 --- a/git-workon-review/src/stage_op.rs +++ b/git-workon-review/src/stage_op.rs @@ -21,12 +21,16 @@ use crate::error::{ApplyError, ReviewError}; use crate::model::FileChange; use crate::ops; use crate::queue::{OpContext, StagingOp}; +use crate::synthesis::LineSelection; /// A queued staging action over one captured [`FileChange`]: a hunk op when `hunk_idx` is /// `Some`, a whole-file op when `None`. The `FileChange` is cloned at enqueue time (the file /// list is rebuilt on the next refresh), but the DIRECTION is fixed by `verb` at construction — /// M4 uses deterministic pane-role direction (locked decision #1), not the queue's live-index /// toggle, so there's no snapshot-staleness to resolve inside `run`. +/// +/// Line-precise selections do NOT use this type — see [`LineSelectionOp`], which applies a +/// (possibly multi-hunk) selection as one merged patch rather than per-hunk ops. pub struct FileStagingOp { file: FileChange, hunk_idx: Option, @@ -66,6 +70,45 @@ impl StagingOp for FileStagingOp { } } +/// A queued line-selection staging action: applies `verb` to `file`'s selected lines, across +/// possibly-multiple hunks, as ONE combined patch via [`ops::apply_line_selections`]. +/// +/// Deliberately NOT one [`FileStagingOp`] per hunk: each per-hunk patch is synthesized from the +/// unstaged (index ↔ worktree) diff, so its line numbers assume every OTHER selected hunk's +/// change is already present in the file. Draining N independent per-hunk ops against the index +/// one at a time — which does NOT yet have the other hunks' changes — leaves each later op's +/// patch header inconsistent with the index's actual state, and libgit2 (strict, no fuzz) rejects +/// it. [`ops::apply_line_selections`] merges every selected hunk into one patch before applying, +/// so the whole selection lands in a single, internally-consistent apply. +pub struct LineSelectionOp { + file: FileChange, + selections: Vec<(usize, LineSelection)>, + verb: StageVerb, +} + +impl LineSelectionOp { + pub fn new(file: FileChange, selections: Vec<(usize, LineSelection)>, verb: StageVerb) -> Self { + Self { + file, + selections, + verb, + } + } +} + +impl StagingOp for LineSelectionOp { + fn run(&mut self, ctx: &OpContext<'_>) -> Result<(), ApplyError> { + ops::apply_line_selections( + ctx.repo, + ctx.applier, + &self.file, + &self.selections, + self.verb, + ) + .map_err(|err| map_review_error(err, &self.file.path)) + } +} + /// Preserve `ApplyError` (so lock retry still fires); wrap any other `ReviewError` in a single /// defensive `ApplyError::Io`. See the module doc's "error seam". fn map_review_error(err: ReviewError, path: &str) -> ApplyError { diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 476de74..6dc535e 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -65,6 +65,7 @@ enum Action { StageFile, DiscardHunk, DiscardFile, + StartSelection, None, } @@ -102,6 +103,7 @@ fn map_key(pending: &mut Option, key: KeyEvent, pane_height: usize) -> Act KeyCode::Char('S') => Action::StageFile, KeyCode::Char('d') => Action::DiscardHunk, KeyCode::Char('D') => Action::DiscardFile, + KeyCode::Char('v') => Action::StartSelection, KeyCode::Tab => Action::NextFile, KeyCode::BackTab => Action::PrevFile, KeyCode::Char(']') => { @@ -135,6 +137,7 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::StageFile => app.stage_file(), Action::DiscardHunk => app.discard_hunk(), Action::DiscardFile => app.discard_file(), + Action::StartSelection => app.start_selection(), Action::None => {} } false @@ -149,9 +152,19 @@ fn apply_action(app: &mut App, action: Action) -> bool { /// message and performs its normal action. `Resize`/`Tick` do NOT clear it: a redraw or timer /// tick isn't the user acting on the message. /// -/// A pending discard confirm captures the keyboard FIRST (before the notice clear and the normal -/// key map): `y` accepts, `n`/`Esc` cancels, and every other key is swallowed — a modal that -/// neither clears the notice nor runs a normal action while it's up. +/// Esc precedence (highest first): a pending discard confirm > an active line selection > the +/// normal key map (where Esc quits). Concretely: +/// +/// 1. A pending discard confirm captures the keyboard FIRST (before the notice clear and the +/// normal key map): `y` accepts, `n`/`Esc` cancels, and every other key is swallowed — a modal +/// that neither clears the notice nor runs a normal action while it's up. +/// 2. Otherwise, with an active line selection, Esc CANCELS the selection instead of quitting (`q` +/// still quits). Other keys fall through to the normal map — `j`/`k` extend the selection, +/// `s`/`d` act on it. +/// 3. Otherwise the normal map applies, where Esc (like `q`) quits. +/// +/// A `Key` event clears any showing footer notice before applying its own action (cases 2 and 3); +/// the confirm modal (case 1) deliberately does not. fn update(app: &mut App, pending: &mut Option, event: AppEvent) -> bool { match event { AppEvent::Key(key) if app.pending_confirm.is_some() => { @@ -164,6 +177,11 @@ fn update(app: &mut App, pending: &mut Option, event: AppEvent) -> bool { } false } + AppEvent::Key(key) if app.selection_anchor.is_some() && key.code == KeyCode::Esc => { + app.clear_notice(); + app.cancel_selection(); + false + } AppEvent::Key(key) => { app.clear_notice(); apply_action(app, map_key(pending, key, app.pane_height)) @@ -511,6 +529,56 @@ mod tests { ); } + #[test] + fn v_maps_to_start_selection() { + let mut pending = None; + assert_eq!( + map_key(&mut pending, key(KeyCode::Char('v')), 20), + Action::StartSelection + ); + } + + #[test] + fn esc_precedence_confirm_over_selection_over_quit() { + use git_workon_fixture::prelude::*; + use workon_review::app::PendingOp; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let mut pending = None; + + // Lowest precedence: with neither a confirm nor a selection up, Esc quits. + assert!( + update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Esc))), + "Esc quits when nothing modal is active" + ); + + // Middle precedence: an active selection makes Esc cancel the selection (not quit). + app.start_selection(); + assert!(app.selection_anchor.is_some()); + let quit = update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Esc))); + assert!(!quit, "Esc must not quit while a selection is active"); + assert!( + app.selection_anchor.is_none(), + "Esc cancels the active selection" + ); + + // Highest precedence: a pending confirm captures Esc as a cancel, even with a selection up. + app.start_selection(); + app.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); + let quit = update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Esc))); + assert!(!quit, "Esc must not quit while a confirm is pending"); + assert!( + app.pending_confirm.is_none(), + "the confirm arm consumes Esc first" + ); + } + #[test] fn pending_confirm_captures_y_and_n_and_ignores_other_keys() { use git_workon_fixture::prelude::*; From 9138c236a7821cb5c268f579ba7d4dfacb76c8f9 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 02:14:12 -0400 Subject: [PATCH 035/203] feat(review): refresh on external index changes via tick poll --- git-workon-review/src/app.rs | 194 ++++++++++++++++++++++++++++++++++- git-workon-review/src/tui.rs | 54 ++++++++-- 2 files changed, 235 insertions(+), 13 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index fcfa29f..581fceb 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -21,6 +21,7 @@ use crate::highlight::{FgSpan, TsHighlighter}; use crate::model::{DiffModel, FileChange, FileStatus, Hunk, LineKind}; use crate::ops; use crate::queue::{OpOutcome, StagingOp, StagingQueue}; +use crate::refresh::{IndexSignature, RefreshCoordinator}; use crate::stage_op::{FileStagingOp, LineSelectionOp}; use crate::synthesis::LineSelection; use crate::wordiff::{word_diff_spans, Span}; @@ -547,6 +548,10 @@ pub struct App { /// zoom change, file switch, split-focus swap — since a raw row index carries no meaning across /// a reshape. pub selection_anchor: Option, + /// Trap-4/5 livelock/interlock state for the M4 index watcher (locked decision #4: a + /// synchronous poll-on-`Tick`, no threads). See [`Self::on_tick`] and + /// [`Self::coordinated_refresh`]. + refresh_coordinator: RefreshCoordinator, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -608,6 +613,19 @@ impl App { staged_idx, } = DiffState::from(diffs); let n = files.len(); + let mut refresh_coordinator = RefreshCoordinator::new(); + // Seed the coordinator with the index signature as it stands right after this initial + // diff, so the FIRST `Tick` doesn't see an "unseen" signature and spuriously re-diff an + // index that hasn't actually changed since `App::new` read it. `begin`/`complete` with no + // refresh in between is just a way to prime `last_signature` through the same API a real + // refresh uses — there's nothing to commit/supersede here, only one coordinator exists. If + // the initial read fails (e.g. a repo with no index file yet), leave it unseeded: the + // first tick will see a "new" signature and refresh once, which is harmless — cheaper than + // threading an extra error path through `new`. + if let Ok(sig) = IndexSignature::read(repo.path()) { + let ticket = refresh_coordinator.begin(); + refresh_coordinator.complete(ticket, sig); + } Self { repo, files, @@ -634,6 +652,51 @@ impl App { applier: Git2Applier, pending_confirm: None, selection_anchor: None, + refresh_coordinator, + } + } + + /// 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 + /// again. `repo.path()` is the `.git` directory itself, which [`IndexSignature::read`] expects. + fn index_signature(&self) -> Option { + IndexSignature::read(self.repo.path()).ok() + } + + /// Refresh wrapped with [`RefreshCoordinator`] bookkeeping — the entry point every refresh + /// trigger (manual `r`, and the post-staging-op drain) must go through instead of calling + /// [`Self::refresh`] directly, so `last_signature` stays current and a `Tick` right after + /// doesn't mistake our own write for an external one (trap 5's echo-suppression). + /// + /// The signature is read AFTER `self.refresh()` runs, not before: `refresh`'s own diffing can + /// itself touch the index's stat cache (see [`RefreshCoordinator::complete`]'s doc comment for + /// why the post-completion signature, not the pre-refresh one, is the one that must be + /// recorded). If the post-refresh read fails, `last_signature` simply isn't updated this + /// round — the next tick will see a "new" signature and refresh again, which is a harmless + /// extra re-diff, not a crash. + pub fn coordinated_refresh(&mut self) { + let ticket = self.refresh_coordinator.begin(); + self.refresh(); + if let Some(sig) = self.index_signature() { + self.refresh_coordinator.complete(ticket, sig); + } + } + + /// The periodic `Tick` hook (locked decision #4: sync poll, no threads/channels). Reads the + /// current index signature and, if [`RefreshCoordinator::note_index_event`] says it's a + /// genuinely new, unseen state with no staging op in flight, runs a [`Self::coordinated_refresh`]. + /// A failed signature read is a silent no-op (tolerated, see [`Self::index_signature`]) — the + /// next tick just tries again. + pub fn on_tick(&mut self) { + let Some(sig) = self.index_signature() else { + return; + }; + if self + .refresh_coordinator + .note_index_event(sig, self.queue.len()) + { + self.coordinated_refresh(); } } @@ -1390,7 +1453,7 @@ impl App { }); match failure { Some(message) => self.notify(message, Severity::Error), - None => self.refresh(), + None => self.coordinated_refresh(), } } @@ -1811,7 +1874,7 @@ mod tests { use git_workon_fixture::prelude::*; use super::test_support::app_from_fixture; - use super::{find_next_hunk_row, find_prev_hunk_row}; + use super::{find_next_hunk_row, find_prev_hunk_row, Role}; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::model::FileStatus; @@ -2768,6 +2831,133 @@ mod tests { assert_eq!(app.zoom, Zoom::Combined, "refresh must not reset zoom"); } + // ---- M4 index watcher (`on_tick`) ------------------------------------------------------- + + /// Stage `path` in the fixture's index, exactly as an external `git add` would — the write + /// [`App::on_tick`] is meant to notice, since [`crate::refresh::IndexSignature`] only + /// fingerprints `.git/index` (not the worktree; see that module's docs for why the watcher is + /// index-only, not a general filesystem watcher). + fn stage_externally(fixture: &Fixture, path: &str) { + let repo = fixture.repo().unwrap(); + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new(path)).unwrap(); + index.write().unwrap(); + } + + #[test] + fn on_tick_after_external_index_change_rebuilds_the_view() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert!( + app.role_change(app.current, Role::Staged).is_none(), + "a.txt starts unstaged only" + ); + + // An external `git add` — nothing this process did — changes `.git/index`'s signature. + stage_externally(&fixture, "a.txt"); + + app.on_tick(); + + assert!( + app.role_change(app.current, Role::Staged).is_some(), + "on_tick must pick up the externally staged change" + ); + assert!( + app.role_change(app.current, Role::Unstaged).is_none(), + "a.txt is now fully staged, no unstaged sub-diff remains" + ); + } + + #[test] + fn on_tick_with_unchanged_index_does_not_refresh() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + // `start_selection` is a marker a refresh always clears (`reset_panes`, called from + // `open_current` at the end of every refresh, zeroes `selection_anchor`) — if it survives + // a tick, no refresh ran. + app.start_selection(); + assert!(app.selection_anchor.is_some()); + + app.on_tick(); + app.on_tick(); + + assert!( + app.selection_anchor.is_some(), + "an unchanged index must not trigger a refresh" + ); + } + + #[test] + fn on_tick_right_after_new_does_not_spuriously_refresh_the_initial_seed() { + // `App::new` seeds the coordinator with the index signature as it stood at construction + // time, so the FIRST tick — with nothing having changed since — must not treat that + // baseline as a "new" external event. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.start_selection(); + assert!(app.selection_anchor.is_some()); + + app.on_tick(); + + assert!( + app.selection_anchor.is_some(), + "the seeded initial signature must suppress a spurious first-tick refresh" + ); + } + + #[test] + fn on_tick_immediately_after_a_staging_op_does_not_double_refresh() { + // The whole point of recording the POST-refresh signature in `coordinated_refresh`: our + // own staging write's echo must not look like a fresh external change to the very next + // tick. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + + app.stage_file(); + assert!( + app.role_change(app.current, Role::Staged).is_some(), + "stage_file must have staged the whole file" + ); + + // Marker: a refresh clears `selection_anchor` via `reset_panes`; `stage_file`'s own + // `coordinated_refresh` already ran and cleared it once (fine, unobserved). Set a fresh + // marker afterward so the NEXT tick's (non-)refresh is what's under test. + app.start_selection(); + assert!(app.selection_anchor.is_some()); + + app.on_tick(); + + assert!( + app.selection_anchor.is_some(), + "the post-op signature recorded by coordinated_refresh must suppress the echo, \ + so this tick must not refresh again" + ); + } + // ---- M4 staging: hunk identity --------------------------------------------------------- /// A modified file whose only two changes are its first and last line, with a dozen unchanged diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 6dc535e..ce5c467 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -2,9 +2,13 @@ //! //! Ported loop shape from the `review-tui-spike` prototype's `main.rs` (`install_panic_hook`, //! raw-mode + alternate-screen setup, `draw -> quit-check -> next_event -> update`), adapted to -//! read events through [`next_event`] rather than calling crossterm directly from the loop: M4 -//! swaps `next_event`'s internals for an mpsc channel fed by watcher threads without changing -//! the loop shape or [`AppEvent`]'s shape. +//! read events through [`next_event`] rather than calling crossterm directly from the loop. +//! +//! M4's index watcher (locked decision #4) does NOT swap `next_event`'s internals for a +//! channel-fed watcher thread, despite an earlier note here suggesting that direction — the +//! locked decision is a synchronous poll on the existing `Tick` (every `next_event` timeout), +//! comparing [`workon_review::refresh::IndexSignature`] and re-diffing in place via +//! [`App::on_tick`] when it changes. No threads, no `mpsc`, no new deps. use std::io::{self, Stdout}; use std::time::Duration; @@ -19,9 +23,9 @@ use ratatui::Terminal; use workon_review::app::App; use workon_review::render; -/// One event the review loop reacts to. `next_event`'s crossterm-specific mapping is the only -/// piece M4 will replace (for an mpsc channel fed by a file-watcher thread) — the loop and this -/// enum stay the same shape. +/// One event the review loop reacts to. `Tick` is now also the index-watcher's poll beat (see the +/// module doc's note on locked decision #4) — `next_event`'s mapping and this enum otherwise stay +/// the shape M3 built. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppEvent { Key(KeyEvent), @@ -132,7 +136,7 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::ToggleLayout => app.toggle_layout(), Action::CycleZoom => app.cycle_zoom(), Action::ToggleSplitFocus => app.toggle_split_focus(), - Action::Refresh => app.refresh(), + Action::Refresh => app.coordinated_refresh(), Action::StageHunk => app.stage_hunk(), Action::StageFile => app.stage_file(), Action::DiscardHunk => app.discard_hunk(), @@ -143,9 +147,9 @@ fn apply_action(app: &mut App, action: Action) -> bool { false } -/// Apply one [`AppEvent`] to `app`. Returns `true` when the loop should exit (q/Esc). Resize and -/// Tick are no-ops today — ratatui re-measures `body_area` every frame regardless, and Tick -/// exists for M4's periodic-refresh consumers, not M3's read-only loop. +/// Apply one [`AppEvent`] to `app`. Returns `true` when the loop should exit (q/Esc). Resize is a +/// no-op — ratatui re-measures `body_area` every frame regardless. Tick drives +/// [`App::on_tick`], the M4 index watcher's poll (see the module doc). /// /// A `Key` event clears any showing footer notice BEFORE applying the key's own action, so a /// notice stays visible until the user's next keystroke — that same keystroke both dismisses the @@ -186,7 +190,11 @@ fn update(app: &mut App, pending: &mut Option, event: AppEvent) -> bool { app.clear_notice(); apply_action(app, map_key(pending, key, app.pane_height)) } - AppEvent::Resize(_, _) | AppEvent::Tick => false, + AppEvent::Tick => { + app.on_tick(); + false + } + AppEvent::Resize(_, _) => false, } } @@ -503,6 +511,30 @@ mod tests { ); } + #[test] + fn tick_event_through_update_calls_on_tick_without_panicking() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let mut pending = None; + + // A plain Tick with nothing changed externally must be a safe no-op wired all the way + // through `update` — the smoke test for M4's index-watcher hookup (the substantive + // signature-change/echo-suppression assertions live in `app.rs`'s own `on_tick` tests, + // which have direct access to its private state). + let quit = update(&mut app, &mut pending, AppEvent::Tick); + + assert!(!quit, "Tick must never quit the loop"); + assert_eq!(app.files.len(), 1); + assert_eq!(app.files[0].path, "a.txt"); + } + #[test] fn staging_keys_map_to_their_actions() { let mut pending = None; From 4a3749e7b00b876ce7866a2392558cccac9eb6fc Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 02:33:18 -0400 Subject: [PATCH 036/203] docs(rfc): mark M4 staging and zoom milestone done --- docs/rfc/workon-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 0100f97..f1c9aed 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -130,7 +130,7 @@ evidence, not to the conclusion. - **M1 — fixture extensions + lib stack capabilities (test-first).** Fixture: sqlite metadata mode (also finally exercises the lib's primary read path), index-state builders. Lib: `parentBranchRevision` read (both formats) + needs-restack; git-inference StackModel; changeset assembly API (`Vec {branch, base_ref, head_ref, title, current, needs_restack}` + uncommitted layer). Acceptance: existing lib tests green + new capabilities spec'd against fixtures in both metadata formats. - **M2 — trap corpus port.** Diff parser + patch synthesis in the review lib, the six trap items as tests, git2-vs-CLI verdict rendered (and the write-path decision recorded here). Acceptance: round-trip corpus green against real repos. — DONE (2026-07-06): corpus green on both backends; verdict recorded above. - **M3 — renderer + uncommitted source.** Port spike modules; wire changeset → parsed diff → SBS/inline render; file nav; the uncommitted source end-to-end. Acceptance: dogfood-able read-only review of a dirty worktree. — DONE (2026-07-06): combined-zoom read-only review with SBS + inline layouts, collapsed context gaps, word-diff emphasis, tree-sitter highlighting (spike's 8 grammars; syntect deferred), file/hunk nav; dogfooded against a dirty worktree. Port note: the spike's `compose_segments` had a latent first-match span-precedence bug that silently dropped word-level emphasis — fixed here (reverse-order lookup), pinned by a three-way bg test in `render.rs`. -- **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. Design locked 2026-07-06 (plan artifact `iron-lattice`): (1) staging = prototype parity — verbs act only in unstaged/staged panes, combined refuses, direction = pane role (combined-native toggle deferred); (2) cursor-primary nav in all views, scroll derived; (3) full 4-state zoom (`split→combined→unstaged→staged`) with per-file `_gate` downgrade and stacked split panes (per-pane cursor, `w` focus), no collapse debounce; (4) runtime stays sync — poll `IndexSignature` on Tick, synchronous re-diff (no threads/notify dep); (5) queue enqueue+drain same beat, refresh, re-snapshot; (6) footer-swap for refusals/errors + discard confirm; (7) attribution via a new pure `attribute.rs` (membership sets keyed by lnum); (8) line selection in both layouts (inline one-sided, SBS row-pair). Six changesets `m4-cursor → m4-zoom → m4-attribute → m4-notify → m4-staging → m4-watch`. +- **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. Design locked 2026-07-06 (plan artifact `iron-lattice`): (1) staging = prototype parity — verbs act only in unstaged/staged panes, combined refuses, direction = pane role (combined-native toggle deferred); (2) cursor-primary nav in all views, scroll derived; (3) full 4-state zoom (`split→combined→unstaged→staged`) with per-file `_gate` downgrade and stacked split panes (per-pane cursor, `w` focus), no collapse debounce; (4) runtime stays sync — poll `IndexSignature` on Tick, synchronous re-diff (no threads/notify dep); (5) queue enqueue+drain same beat, refresh, re-snapshot; (6) footer-swap for refusals/errors + discard confirm; (7) attribution via a new pure `attribute.rs` (membership sets keyed by lnum); (8) line selection in both layouts (inline one-sided, SBS row-pair). — DONE (2026-07-07): shipped as EIGHT changesets `m4-cursor → m4-zoom → m4-attribute → m4-notify → m4-refresh → m4-stage → m4-select → m4-watch` (staging split into hunk/file vs line selection; refresh pulled out as shared infra for stage + watch). Stack-reviewed continuously on the main thread; two real bugs caught by review, not by agent tests: (a) m4-zoom sub-view panes rendered worktree text where index text belonged — fixed with per-role blob sourcing (`read_index_blob`); (b) m4-select applied a multi-hunk line selection as N independent patches, which libgit2 rejects because each per-hunk patch's line numbering assumes the others are present — fixed by merging into ONE `PatchText` (`ops::apply_line_selections`), pinned by a line-shift tripwire test. Acceptance met: staging parity dogfooded against real git (stage/unstage/discard hunk/file/line, partial-hunk selection); index watcher confirmed live (external `git add` auto-refreshes on the next Tick — the watcher polls `.git/index`'s signature, so it catches index writes, not bare worktree edits, matching its name). Runtime stayed sync (no threads); combined-native staging toggle and spike `--dump`/`--bench` modes remain deferred. - **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). - **M6 — comments + integration.** Comment store + `mcp` subcommand; `$NVIM`/`$EDITOR` edit jump; git-workon external dispatch + completion delegation. Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review. From df9b40caca30e494c532a2d3f6d344b35a0b6faa Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 10:47:44 -0400 Subject: [PATCH 037/203] feat(review): source review from the changeset stack --- git-workon-review/src/acquire.rs | 40 +- git-workon-review/src/app.rs | 659 ++++++++++++++++++++++++------- git-workon-review/src/error.rs | 7 + git-workon-review/src/main.rs | 30 +- git-workon-review/src/render.rs | 10 +- git-workon-review/src/tui.rs | 8 +- 6 files changed, 599 insertions(+), 155 deletions(-) diff --git a/git-workon-review/src/acquire.rs b/git-workon-review/src/acquire.rs index 08f9836..3db5c2d 100644 --- a/git-workon-review/src/acquire.rs +++ b/git-workon-review/src/acquire.rs @@ -6,7 +6,7 @@ //! git2 diffs and then a [`DiffModel`]. use git2::{DiffFindOptions, DiffOptions, Oid, Repository}; -use workon::{Changeset, ChangesetSource}; +use workon::{assemble_changesets, Changeset, ChangesetSource, StackModel}; use crate::error::DiffError; use crate::model::DiffModel; @@ -115,6 +115,40 @@ pub fn diff_changeset(repo: &Repository, cs: &Changeset) -> Result Result, DiffError> { + match StackModel::detect(repo) { + StackModel::Graphite => Ok(assemble_changesets( + repo, + head_branch, + StackModel::Graphite, + )?), + StackModel::None | StackModel::Git => Ok(vec![Changeset { + name: head_branch.to_string(), + source: ChangesetSource::Uncommitted, + title: None, + current: true, + needs_restack: false, + }]), + } +} + /// Fold a [`DiffError`] into [`DiffError::ChangesetDiffFailed`], attaching the changeset name. fn changeset_diff_failed(name: &str, err: DiffError) -> DiffError { match err { @@ -123,5 +157,9 @@ fn changeset_diff_failed(name: &str, err: DiffError) -> DiffError { source, }, already_wrapped @ DiffError::ChangesetDiffFailed { .. } => already_wrapped, + // `diff_committed`/`diff_uncommitted` only ever raise `Git`, so this arm is + // unreachable in practice — kept exhaustive rather than a wildcard so a future + // `DiffError` variant forces a decision here instead of silently falling through. + already_wrapped @ DiffError::StackAssembly(_) => already_wrapped, } } diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 581fceb..adc40a5 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -13,8 +13,9 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::Path; use git2::Repository; +use workon::{Changeset, ChangesetSource}; -use crate::acquire::{diff_uncommitted, WorktreeDiffs}; +use crate::acquire::{ChangesetDiff, WorktreeDiffs}; use crate::align::{align_file, collapse_gaps, inline_rows, CellKind, DisplayRow, InlineRow, Row}; use crate::apply::{Git2Applier, StageVerb}; use crate::highlight::{FgSpan, TsHighlighter}; @@ -292,6 +293,26 @@ impl FileView { } } +/// The tree a COMBINED-role [`FileView`]'s old side reads from (see [`FileView::load`]'s role +/// table): the changeset's `base` commit for a committed changeset, or the live `HEAD` for the +/// uncommitted layer — the only case M2–M4 ever had, and what [`App::base_label`] already +/// names. A committed changeset's combined role is `base..head` (there is no staged/unstaged +/// split to disagree with it — see [`DiffState::from_committed`]), so the old side must read +/// `base`'s blob, not whatever `HEAD` happens to be right now. +/// +/// A free function (not an `App` method) so its returned [`git2::Tree`] borrows only `repo`, +/// not all of `App` — a `&self` method here would make the borrow checker treat the tree as +/// blocking every OTHER field access (e.g. `&mut self.highlighter`) for its whole lifetime, even +/// though the two never actually conflict. +fn old_side_tree_for(repo: &Repository, source: ChangesetSource) -> Option> { + match source { + ChangesetSource::Committed { base, .. } => { + repo.find_commit(base).and_then(|c| c.tree()).ok() + } + ChangesetSource::Uncommitted => repo.head().and_then(|h| h.peel_to_tree()).ok(), + } +} + fn read_head_blob(repo: &Repository, tree: &git2::Tree<'_>, path: &str) -> String { tree.get_path(Path::new(path)) .and_then(|entry| entry.to_object(repo)) @@ -464,30 +485,75 @@ fn derive_scroll_value( scroll.min(max_scroll) } -/// Review session state: the combined diff's file list, per-file lazily loaded views, and +/// One changeset's diff state: its [`workon::Changeset`] descriptor (name, source, restack +/// status), the [`DiffState`] acquired for it, and its own per-file, per-role lazily built +/// [`FileView`] caches — the same three `views_*` vectors [`App`] held directly through M4, +/// now scoped per changeset since M5 reviews more than one at a time. +/// +/// A committed changeset's [`Self::diff`] has empty staged/unstaged sub-models (see +/// [`DiffState::from_committed`]), which is enough on its own to render it read-only: the +/// existing [`effective_zoom`] gate collapses `Split`/`Unstaged`/`Staged` to +/// [`EffectiveZoom::Single(Role::Combined)`] whenever both sub-diffs are absent — no +/// committed-specific rendering code needed for M5's spine (mode-aware staging refusal/zoom +/// lock are `m5-changeset-nav`). +pub struct ChangesetView { + pub cs: Changeset, + diff: DiffState, + /// Per-file, per-role lazily built views (parallel to [`DiffState::files`]). A slot stays + /// `None` until first access; a role slot ALSO stays `None` forever when that file has no + /// change in that role (see [`App::ensure_role_loaded`]). + views_combined: Vec>, + views_unstaged: Vec>, + views_staged: Vec>, +} + +impl ChangesetView { + fn new(cs: Changeset, diff: DiffState) -> Self { + let n = diff.files.len(); + Self { + cs, + diff, + views_combined: (0..n).map(|_| None).collect(), + views_unstaged: (0..n).map(|_| None).collect(), + views_staged: (0..n).map(|_| None).collect(), + } + } + + /// Build the [`ChangesetView`] for `cs` from its acquired [`ChangesetDiff`] (see + /// [`crate::acquire::diff_changeset`]) — the router from "how was this changeset diffed" to + /// the uniform [`DiffState`] shape every [`ChangesetView`] carries. + pub fn from_changeset_diff(cs: Changeset, diff: ChangesetDiff) -> Self { + let diff = match diff { + ChangesetDiff::Committed(model) => DiffState::from_committed(model), + ChangesetDiff::Uncommitted(diffs) => DiffState::from(diffs), + }; + Self::new(cs, diff) + } + + /// Number of files this changeset's diff touches — `App::new_uncommitted`'s "nothing to + /// review" check (and its `main.rs` stack analog) read this rather than reaching into + /// [`Self::diff`] directly, which stays private to this module. + pub fn file_count(&self) -> usize { + self.diff.files.len() + } +} + +/// Review session state: the active changeset's file list, per-file lazily loaded views, and /// navigation/scroll state. One long-lived [`TsHighlighter`] lives here (not per file) — its /// language-config cache is keyed per-instance, so a fresh highlighter per file would rebuild /// every grammar config on every navigation. pub struct App { repo: Repository, - /// The combined diff's files. git2 enumerates these in path order (verified in - /// `tests`), so "current file index" is a stable alphabetical position, not an - /// arrival/discovery order that could reshuffle under the user. The file LIST stays - /// combined-driven even in split/zoom modes — only the rendered rows change per role. - pub files: Vec, - /// The unstaged (index ↔ worktree) sub-diff, and, parallel to [`Self::files`], - /// [`Self::unstaged_idx`] mapping each combined file to its index here (or `None` when that - /// file has no unstaged change). The staged pair mirrors it. - unstaged_model: DiffModel, - staged_model: DiffModel, - unstaged_idx: Vec>, - staged_idx: Vec>, - /// Per-file, per-role lazily built views (parallel to [`Self::files`]). A slot stays `None` - /// until first access; a role slot ALSO stays `None` forever when that file has no change in - /// that role (see [`Self::ensure_role_loaded`]). - views_combined: Vec>, - views_unstaged: Vec>, - views_staged: Vec>, + /// One [`ChangesetView`] per reviewable changeset — the Graphite stack, or a single + /// synthetic uncommitted changeset when no stack is active (see + /// [`crate::acquire::resolve_changesets`]) — in base → head order. Everything that used to + /// be App-level diff state (`files`, the staged/unstaged sub-models + index maps, and the + /// three `views_*` lazy caches) now lives on the ACTIVE entry's [`ChangesetView`]; read it + /// through [`Self::cur`]/[`Self::cur_mut`] rather than indexing this directly. + changesets: Vec, + /// Index into [`Self::changesets`] of the active changeset. M5 changeset-nav (`]c`/`[c`) + /// will move this; M5's spine (this changeset) only ever sets it at construction/refresh. + current_cs: usize, pub current: usize, /// Row index, in the ACTIVE layout's coordinate space, of the highlighted navigation /// anchor — THE nav state (locked decision #2 in the M4 plan). In a split this is the @@ -604,45 +670,65 @@ pub struct Notice { } impl App { + /// Build an [`App`] reviewing a single uncommitted changeset — the M2–M4 shape, and still + /// what a non-Graphite (or clean-Graphite-tip) repo degrades to under M5's auto-detect + /// (locked decision #7): a one-element [`Self::changesets`], `current_cs = 0`, + /// `base_label = "HEAD"`. `test_support::app_from_fixture` and every existing M2–M4 test + /// build through this constructor unchanged. pub fn new(repo: Repository, diffs: WorktreeDiffs) -> Self { - let DiffState { - files, - unstaged_model, - staged_model, - unstaged_idx, - staged_idx, - } = DiffState::from(diffs); - let n = files.len(); + let name = repo + .head() + .ok() + .and_then(|h| h.shorthand().ok().map(str::to_string)) + .unwrap_or_default(); + let cs = Changeset { + name, + source: ChangesetSource::Uncommitted, + title: None, + current: true, + needs_restack: false, + }; + let view = ChangesetView::new(cs, DiffState::from(diffs)); + Self::from_changesets(repo, vec![view]) + } + + /// Build an [`App`] over an already-diffed changeset stack — `main.rs`'s entry point for + /// both the Graphite-stack and single-uncommitted-changeset cases (the latter goes through + /// [`Self::new`] instead, which is the same thing for a one-element stack). Opens on + /// whichever changeset the lib marked `current` (locked decision #6: "honor lib `current`, + /// first file"), falling back to index `0` if none is marked. An empty `changesets` panics — + /// `main.rs` and [`Self::new`] never call this with one. + pub fn from_changesets(repo: Repository, changesets: Vec) -> Self { + assert!( + !changesets.is_empty(), + "App::from_changesets requires at least one changeset" + ); + let current_cs = current_cs_index(&changesets); + let base_label = base_label_for(&changesets[current_cs].cs); let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial // diff, so the FIRST `Tick` doesn't see an "unseen" signature and spuriously re-diff an - // index that hasn't actually changed since `App::new` read it. `begin`/`complete` with no + // index that hasn't actually changed since construction. `begin`/`complete` with no // refresh in between is just a way to prime `last_signature` through the same API a real // refresh uses — there's nothing to commit/supersede here, only one coordinator exists. If // the initial read fails (e.g. a repo with no index file yet), leave it unseeded: the // first tick will see a "new" signature and refresh once, which is harmless — cheaper than - // threading an extra error path through `new`. + // threading an extra error path through construction. if let Ok(sig) = IndexSignature::read(repo.path()) { let ticket = refresh_coordinator.begin(); refresh_coordinator.complete(ticket, sig); } Self { repo, - files, - unstaged_model, - staged_model, - unstaged_idx, - staged_idx, - views_combined: (0..n).map(|_| None).collect(), - views_unstaged: (0..n).map(|_| None).collect(), - views_staged: (0..n).map(|_| None).collect(), + changesets, + current_cs, current: 0, cursor: 0, scroll: 0, pane_height: 20, alt: PaneState::default(), alt_height: 20, - base_label: "HEAD".to_string(), + base_label, highlighter: TsHighlighter::new(), layout: Layout::default(), zoom: Zoom::default(), @@ -700,21 +786,59 @@ impl App { } } - /// Re-run [`diff_uncommitted`] and rebuild every diff-derived field in place — the operation - /// both a manual refresh (`r`) and (later) a post-staging-op/external-write refresh need. See - /// the M4 plan's changeset 5 for the full contract; summarized: + /// The active changeset's view — every read site that used to reach an App-level diff field + /// directly now goes through this (or [`Self::cur_mut`]). + fn cur(&self) -> &ChangesetView { + &self.changesets[self.current_cs] + } + + /// Mutable analog of [`Self::cur`]. + fn cur_mut(&mut self) -> &mut ChangesetView { + &mut self.changesets[self.current_cs] + } + + /// The active changeset's combined file list — `render.rs` and tests read this instead of + /// the old `pub files` field, which moved onto [`ChangesetView`] (see [`Self::cur`]). + pub fn files(&self) -> &[FileChange] { + &self.cur().diff.files + } + + /// Index into the reviewed stack of the active changeset — read by tests asserting the + /// [`Self::from_changesets`]/[`Self::refresh`] "honor lib `current`" rule (locked decision + /// #6); changeset-nav (`]c`/`[c`) will add a setter in `m5-changeset-nav`. + pub fn current_cs(&self) -> usize { + self.current_cs + } + + /// The active changeset's descriptor (name, source, restack status) — read by tests + /// asserting which changeset [`Self::current_cs`] landed on. + pub fn current_changeset(&self) -> &Changeset { + &self.cur().cs + } + + /// Number of changesets in the reviewed stack — `1` for a non-Graphite (or + /// clean-Graphite-tip) repo, per locked decision #7. + pub fn changeset_count(&self) -> usize { + self.changesets.len() + } + + /// Re-run [`crate::acquire::resolve_changesets`] against the CURRENT `HEAD` branch and + /// rebuild every [`ChangesetView`] from scratch — the operation both a manual refresh (`r`) + /// and (later) a post-staging-op/external-write refresh need. Re-assembling (not just + /// re-diffing the active changeset) matters because a restack can change the stack's + /// topology, not just its diffs. /// - /// - Rebuilds exactly what [`Self::new`] builds from a fresh [`WorktreeDiffs`]: `files`, - /// `unstaged_model`/`staged_model`, `unstaged_idx`/`staged_idx`, and all three `views_*` - /// (reset to `None` — lazily reloaded, same as a fresh `App`). - /// - Does NOT touch `repo` (same handle), `highlighter` (its per-instance grammar cache would - /// have to re-parse every language from scratch if rebuilt), `base_label`, `layout`, or - /// `zoom` (the user's current view mode shouldn't reset just because they pressed `r`, or - /// because a background refresh fired). - /// - Preserves position by the current file's PATH: if a file with that path still exists in - /// the rebuilt list, `current` follows it (even if its index moved, e.g. a file alphabetically - /// before it in the old list got fully staged away). If it vanished (fully staged or - /// reverted), `current` clamps into the new list (or `0` if it's now empty). + /// - Rebuilds [`Self::changesets`] and [`Self::base_label`] in place. Does NOT touch `repo` + /// (same handle), `highlighter` (its per-instance grammar cache would have to re-parse + /// every language from scratch if rebuilt), `layout`, or `zoom` (the user's current view + /// mode shouldn't reset just because they pressed `r`, or because a background refresh + /// fired). + /// - Preserves the active changeset by NAME: if a changeset with that name still exists in + /// the rebuilt stack, `current_cs` follows it; otherwise it falls back to whichever + /// changeset the lib now reports as `current`, or index `0`. + /// - Preserves file position by PATH within the (possibly different) active changeset, same + /// rule M4 used: `current` follows the path if it still exists, else clamps into the new + /// list (or `0` if empty). /// - Re-seats the (possibly changed) current file at its first hunk via [`Self::open_current`] /// — the same path a file switch already uses. This does NOT try to preserve the exact /// cursor row: the rows under an old cursor position may no longer correspond to the same @@ -722,41 +846,64 @@ impl App { /// is the only always-valid choice, consistent with how zoom/layout switches already treat /// cursor position as non-transferable across a reshape. /// - /// On a [`diff_uncommitted`] error, leaves all existing state untouched and sets an error + /// On any assembly/diff error, leaves all existing state untouched and sets an error /// [`Notice`] instead (via [`Self::notify`]) — a failed refresh must never blank the review. pub fn refresh(&mut self) { - let diffs = match diff_uncommitted(&self.repo) { - Ok(diffs) => diffs, + let Some(head_branch) = self + .repo + .head() + .ok() + .and_then(|h| h.shorthand().ok().map(str::to_string)) + else { + self.notify("refresh failed: no current branch", Severity::Error); + return; + }; + + let changesets = match crate::acquire::resolve_changesets(&self.repo, &head_branch) { + Ok(cs) => cs, Err(err) => { self.notify(format!("refresh failed: {err}"), Severity::Error); return; } }; - let current_path = self.files.get(self.current).map(|f| f.path.clone()); + let mut views = Vec::with_capacity(changesets.len()); + for cs in changesets { + match crate::acquire::diff_changeset(&self.repo, &cs) { + Ok(diff) => views.push(ChangesetView::from_changeset_diff(cs, diff)), + Err(err) => { + self.notify(format!("refresh failed: {err}"), Severity::Error); + return; + } + } + } + // `resolve_changesets` always returns at least one changeset (a lone Uncommitted entry + // when no stack is active), but stay defensive rather than index an empty `Vec` below. + if views.is_empty() { + self.notify("refresh failed: no changesets to review", Severity::Error); + return; + } - let DiffState { - files, - unstaged_model, - staged_model, - unstaged_idx, - staged_idx, - } = DiffState::from(diffs); - let n = files.len(); + let prev_cs_name = self.cur().cs.name.clone(); + let current_path = self + .cur() + .diff + .files + .get(self.current) + .map(|f| f.path.clone()); + + self.current_cs = views + .iter() + .position(|v| v.cs.name == prev_cs_name) + .unwrap_or_else(|| current_cs_index(&views)); + self.base_label = base_label_for(&views[self.current_cs].cs); + self.changesets = views; + let n = self.cur().diff.files.len(); self.current = current_path - .and_then(|path| files.iter().position(|f| f.path == path)) + .and_then(|path| self.cur().diff.files.iter().position(|f| f.path == path)) .unwrap_or(if n == 0 { 0 } else { self.current.min(n - 1) }); - self.files = files; - self.unstaged_model = unstaged_model; - self.staged_model = staged_model; - self.unstaged_idx = unstaged_idx; - self.staged_idx = staged_idx; - self.views_combined = (0..n).map(|_| None).collect(); - self.views_unstaged = (0..n).map(|_| None).collect(); - self.views_staged = (0..n).map(|_| None).collect(); - self.open_current(); } @@ -764,9 +911,29 @@ impl App { /// against that file's available sub-diffs and stageability. Cheap (three lookups + the pure /// [`effective_zoom`]) — re-evaluated per file per frame, no caching (locked decision #3). pub(crate) fn effective_zoom_for(&self, idx: usize) -> EffectiveZoom { - let can_stage = self.files.get(idx).map(|f| !f.is_binary).unwrap_or(false); - let has_unstaged = self.unstaged_idx.get(idx).copied().flatten().is_some(); - let has_staged = self.staged_idx.get(idx).copied().flatten().is_some(); + let can_stage = self + .cur() + .diff + .files + .get(idx) + .map(|f| !f.is_binary) + .unwrap_or(false); + let has_unstaged = self + .cur() + .diff + .unstaged_idx + .get(idx) + .copied() + .flatten() + .is_some(); + let has_staged = self + .cur() + .diff + .staged_idx + .get(idx) + .copied() + .flatten() + .is_some(); effective_zoom(self.zoom, has_unstaged, has_staged, can_stage) } @@ -785,20 +952,21 @@ impl App { /// [`crate::attribute::Attribution`] for the combined role each frame — see that module's /// docs for why the two sub-roles' hunks (not the combined ones) are the attribution source. pub(crate) fn role_change(&self, idx: usize, role: Role) -> Option<&FileChange> { + let diff = &self.cur().diff; match role { - Role::Combined => self.files.get(idx), - Role::Unstaged => self + Role::Combined => diff.files.get(idx), + Role::Unstaged => diff .unstaged_idx .get(idx) .copied() .flatten() - .map(|mi| &self.unstaged_model.files[mi]), - Role::Staged => self + .map(|mi| &diff.unstaged_model.files[mi]), + Role::Staged => diff .staged_idx .get(idx) .copied() .flatten() - .map(|mi| &self.staged_model.files[mi]), + .map(|mi| &diff.staged_model.files[mi]), } } @@ -819,18 +987,20 @@ impl App { } fn views_for(&self, role: Role) -> &[Option] { + let cur = self.cur(); match role { - Role::Combined => &self.views_combined, - Role::Unstaged => &self.views_unstaged, - Role::Staged => &self.views_staged, + Role::Combined => &cur.views_combined, + Role::Unstaged => &cur.views_unstaged, + Role::Staged => &cur.views_staged, } } fn views_for_mut(&mut self, role: Role) -> &mut [Option] { + let cur = self.cur_mut(); match role { - Role::Combined => &mut self.views_combined, - Role::Unstaged => &mut self.views_unstaged, - Role::Staged => &mut self.views_staged, + Role::Combined => &mut cur.views_combined, + Role::Unstaged => &mut cur.views_unstaged, + Role::Staged => &mut cur.views_staged, } } @@ -870,19 +1040,19 @@ impl App { fn ensure_role_loaded(&mut self, idx: usize, role: Role) { let model_idx = match role { Role::Combined => { - let Some(file) = self.files.get(idx) else { + let Some(file) = self.cur().diff.files.get(idx) else { return; }; if file.is_binary { return; } - if self.views_combined.get(idx).map(Option::is_some) != Some(false) { + if self.cur().views_combined.get(idx).map(Option::is_some) != Some(false) { return; } None } - Role::Unstaged => self.unstaged_idx.get(idx).copied().flatten(), - Role::Staged => self.staged_idx.get(idx).copied().flatten(), + Role::Unstaged => self.cur().diff.unstaged_idx.get(idx).copied().flatten(), + Role::Staged => self.cur().diff.staged_idx.get(idx).copied().flatten(), }; if role != Role::Combined { @@ -893,8 +1063,8 @@ impl App { return; // already loaded (or slot absent) } let file = match role { - Role::Unstaged => &self.unstaged_model.files[mi], - Role::Staged => &self.staged_model.files[mi], + Role::Unstaged => self.cur().diff.unstaged_model.files[mi].clone(), + Role::Staged => self.cur().diff.staged_model.files[mi].clone(), Role::Combined => unreachable!(), }; if file.is_binary { @@ -902,32 +1072,41 @@ impl App { } // Build the view in a block so `head_tree` (which borrows `self.repo`) drops before // the `views_for_mut` reborrow — same reason the combined path below can assign a - // direct field while `head_tree` is live but this method-call path cannot. + // direct field while `head_tree` is live but this method-call path cannot. `file` is + // cloned out of `self.cur()` for the same reason: `FileView::load` needs `&self.repo` + // and `&mut self.highlighter` at once, which a borrow still anchored in `self.cur()` + // would conflict with. let view = { // Re-peeled per call, same rationale as the combined path below. let Ok(head_tree) = self.repo.head().and_then(|h| h.peel_to_tree()) else { return; }; - FileView::load(&self.repo, &head_tree, file, role, &mut self.highlighter) + FileView::load(&self.repo, &head_tree, &file, role, &mut self.highlighter) }; self.views_for_mut(role)[idx] = Some(view); return; } // Combined role. - // Re-peeled per call rather than cached on `App`: HEAD can move between file loads and the - // tree is cheap to re-peel. - let Ok(head_tree) = self.repo.head().and_then(|h| h.peel_to_tree()) else { + // Re-peeled per call rather than cached on `App`: for the uncommitted layer `HEAD` can + // move between file loads, and the tree is cheap to re-peel either way. + // `self.cur().cs.source` is `Copy`, so reading it here borrows `self` only for this + // sub-expression — `head_tree` itself ends up borrowing `self.repo` alone (via the free + // `old_side_tree_for`), leaving `&mut self.highlighter` free below. A method tied to + // `&self` would instead have bound the tree's lifetime to all of `self`. + let Some(head_tree) = old_side_tree_for(&self.repo, self.cur().cs.source) else { return; }; + let file = self.cur().diff.files[idx].clone(); let view = FileView::load( &self.repo, &head_tree, - &self.files[idx], + &file, Role::Combined, &mut self.highlighter, ); - self.views_combined[idx] = Some(view); + drop(head_tree); + self.cur_mut().views_combined[idx] = Some(view); } pub fn current_view(&mut self) -> Option<&mut FileView> { @@ -1025,18 +1204,19 @@ impl App { } pub fn next_file(&mut self) { - if self.files.is_empty() { + if self.cur().diff.files.is_empty() { return; } - self.current = (self.current + 1) % self.files.len(); + self.current = (self.current + 1) % self.cur().diff.files.len(); self.open_current(); } pub fn prev_file(&mut self) { - if self.files.is_empty() { + if self.cur().diff.files.is_empty() { return; } - self.current = (self.current + self.files.len() - 1) % self.files.len(); + self.current = + (self.current + self.cur().diff.files.len() - 1) % self.cur().diff.files.len(); self.open_current(); } @@ -1269,7 +1449,7 @@ impl App { self.stage_selection(); return; } - if self.files.is_empty() { + if self.cur().diff.files.is_empty() { return; } let Some(role) = self.staging_role() else { @@ -1297,7 +1477,7 @@ impl App { /// Stage (unstaged pane) or unstage (staged pane) the whole current file (`S`) — ignores the /// cursor. Refuses on the combined view. pub fn stage_file(&mut self) { - if self.files.is_empty() { + if self.cur().diff.files.is_empty() { return; } let Some(role) = self.staging_role() else { @@ -1312,7 +1492,7 @@ impl App { }; // A whole-file op routes on path + status only ([`crate::ops::apply_file`]), which the // combined file carries authoritatively (e.g. Untracked-ness for a discard). - let file = self.files[self.current].clone(); + let file = self.cur().diff.files[self.current].clone(); self.run_op(FileStagingOp::file(file, verb)); } @@ -1327,7 +1507,7 @@ impl App { self.discard_selection(); return; } - if self.files.is_empty() { + if self.cur().diff.files.is_empty() { return; } let Some(role) = self.staging_role() else { @@ -1357,7 +1537,7 @@ impl App { /// Request confirmation to discard the whole current file's worktree changes (`D`). Refuses on /// the combined view or in a staged pane; the discard runs on `y`. pub fn discard_file(&mut self) { - if self.files.is_empty() { + if self.cur().diff.files.is_empty() { return; } let Some(role) = self.staging_role() else { @@ -1371,7 +1551,7 @@ impl App { self.notify("discard acts in the unstaged pane", Severity::Error); return; } - let path = self.files[self.current].path.clone(); + let path = self.cur().diff.files[self.current].path.clone(); self.request_confirm( format!("Discard all changes to `{path}`? (y/n)"), PendingOp::DiscardFile { @@ -1412,7 +1592,7 @@ impl App { self.run_op(FileStagingOp::hunk(file, hunk_idx, StageVerb::Discard)); } PendingOp::DiscardFile { file_idx } => { - let Some(file) = self.files.get(file_idx).cloned() else { + let Some(file) = self.cur().diff.files.get(file_idx).cloned() else { return; }; self.run_op(FileStagingOp::file(file, StageVerb::Discard)); @@ -1461,7 +1641,7 @@ impl App { /// set) on the combined view or any non-staging role — you can only select lines where you can /// stage them (same gate as the verbs). A no-op on an empty file list. pub fn start_selection(&mut self) { - if self.files.is_empty() { + if self.cur().diff.files.is_empty() { return; } if self.staging_role().is_none() { @@ -1583,7 +1763,7 @@ impl App { /// 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.files.is_empty() { + if self.cur().diff.files.is_empty() { self.cancel_selection(); return; } @@ -1597,7 +1777,7 @@ impl App { let Some(verb) = Self::verb_for_role(role) else { return; }; - if !ops::is_hunk_patchable(&self.files[self.current]) { + 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, @@ -1621,7 +1801,7 @@ impl App { /// 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`. fn discard_selection(&mut self) { - if self.files.is_empty() { + if self.cur().diff.files.is_empty() { self.cancel_selection(); return; } @@ -1636,7 +1816,7 @@ impl App { self.notify("discard acts in the unstaged pane", Severity::Error); return; } - if !ops::is_hunk_patchable(&self.files[self.current]) { + 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, @@ -1696,10 +1876,10 @@ fn line_selection_for_hunk( sel } -/// The diff-derived pieces [`App::new`] and [`App::refresh`] both build fresh from a -/// [`WorktreeDiffs`] snapshot — everything EXCEPT the view caches (which the two callers reset -/// differently sized `None` vectors for) and the navigation/UI state that survives a refresh -/// (`current`, `cursor`, `layout`, `zoom`, etc. — see [`App::refresh`]'s doc comment). +/// The diff-derived pieces one [`ChangesetView`] carries — everything EXCEPT the view caches +/// (which [`ChangesetView::new`] and [`App::refresh`] reset to freshly-sized `None` vectors) and +/// the App-level navigation/UI state that survives a refresh (`current`, `cursor`, `layout`, +/// `zoom`, etc. — see [`App::refresh`]'s doc comment). struct DiffState { files: Vec, unstaged_model: DiffModel, @@ -1731,6 +1911,45 @@ impl From for DiffState { } } +impl DiffState { + /// Build a [`DiffState`] for a COMMITTED changeset's [`DiffModel`] (`base..head`, already + /// diffed by [`crate::acquire::diff_committed`]) — there is no staged/unstaged split for a + /// committed range, so both sub-models are empty and every index map entry is `None`. This + /// alone is enough to render the changeset read-only: [`effective_zoom`] collapses + /// `Split`/`Unstaged`/`Staged` to [`EffectiveZoom::Single(Role::Combined)`] whenever both + /// sub-diffs are absent, so no committed-specific rendering path is needed for M5's spine. + fn from_committed(model: DiffModel) -> Self { + let n = model.files.len(); + Self { + files: model.files, + unstaged_model: DiffModel { files: Vec::new() }, + staged_model: DiffModel { files: Vec::new() }, + unstaged_idx: (0..n).map(|_| None).collect(), + staged_idx: (0..n).map(|_| None).collect(), + } + } +} + +/// Index of the changeset the lib marked `current` (locked decision #6), or `0` if none is — +/// the shared rule [`App::from_changesets`] uses to open, and [`App::refresh`] falls back to +/// when the previously-active changeset's name no longer exists after a re-assembly. +fn current_cs_index(changesets: &[ChangesetView]) -> usize { + changesets.iter().position(|v| v.cs.current).unwrap_or(0) +} + +/// [`App::base_label`] for the changeset that would become active — a committed changeset's +/// base rev (7-char short-sha), or `"HEAD"` for the uncommitted layer (worktree ↔ `HEAD`, +/// unchanged from M2–M4). +fn base_label_for(cs: &Changeset) -> String { + match cs.source { + ChangesetSource::Committed { base, .. } => { + let full = base.to_string(); + full.chars().take(7).collect() + } + ChangesetSource::Uncommitted => "HEAD".to_string(), + } +} + /// Index of the [`FileChange`] in a role's [`DiffModel`] that corresponds to combined `file`, or /// `None` when the role has no change for it (e.g. an untracked file in the staged model). /// @@ -1871,10 +2090,12 @@ pub(crate) mod test_support { #[cfg(test)] mod tests { + use git2::Repository; use git_workon_fixture::prelude::*; + use workon::{Changeset, ChangesetSource}; use super::test_support::app_from_fixture; - use super::{find_next_hunk_row, find_prev_hunk_row, Role}; + use super::{find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, EffectiveZoom, Role}; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::model::FileStatus; @@ -1889,7 +2110,7 @@ mod tests { .unwrap(); let app = app_from_fixture(&fixture); - let paths: Vec<&str> = app.files.iter().map(|f| f.path.as_str()).collect(); + let paths: Vec<&str> = app.files().iter().map(|f| f.path.as_str()).collect(); assert_eq!(paths, vec!["a_tracked.txt", "m_mid.txt", "z_new.txt"]); } @@ -1917,7 +2138,7 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); - assert_eq!(app.files[0].status, FileStatus::Added); + assert_eq!(app.files()[0].status, FileStatus::Added); app.ensure_loaded(0); let view = app.current_view_ref().unwrap(); assert_eq!(view.old_text(), ""); @@ -1933,7 +2154,7 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); - assert_eq!(app.files[0].status, FileStatus::Deleted); + assert_eq!(app.files()[0].status, FileStatus::Deleted); app.ensure_loaded(0); let view = app.current_view_ref().unwrap(); assert_eq!(view.old_text(), "bye\n"); @@ -1952,9 +2173,9 @@ mod tests { std::fs::rename(workdir.join("old_name.txt"), workdir.join("new_name.txt")).unwrap(); let mut app = app_from_fixture(&fixture); - assert_eq!(app.files.len(), 1); - assert_eq!(app.files[0].status, FileStatus::Renamed); - assert_eq!(app.files[0].old_path.as_deref(), Some("old_name.txt")); + assert_eq!(app.files().len(), 1); + assert_eq!(app.files()[0].status, FileStatus::Renamed); + assert_eq!(app.files()[0].old_path.as_deref(), Some("old_name.txt")); app.ensure_loaded(0); let view = app.current_view_ref().unwrap(); assert_eq!(view.old_text(), "same content\n"); @@ -1974,7 +2195,7 @@ mod tests { std::fs::write(repo.workdir().unwrap().join("bin.dat"), [0u8, 1, 2, 0, 3]).unwrap(); let mut app = app_from_fixture(&fixture); - assert!(app.files[0].is_binary); + assert!(app.files()[0].is_binary); app.ensure_loaded(0); assert!(app.current_view_ref().is_none()); } @@ -2208,7 +2429,7 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); - assert!(app.files.is_empty(), "fixture must have no dirty files"); + assert!(app.files().is_empty(), "fixture must have no dirty files"); app.move_cursor_by(5); app.move_cursor_by(-5); @@ -2731,12 +2952,13 @@ mod tests { app.open_current(); app.next_file(); assert_eq!(app.current, 1, "path-sorted: b.txt is index 1"); - let path = app.files[app.current].path.clone(); + let path = app.files()[app.current].path.clone(); app.refresh(); assert_eq!( - app.files[app.current].path, path, + app.files()[app.current].path, + path, "refresh must keep tracking the same file by path" ); } @@ -2763,13 +2985,13 @@ mod tests { app.refresh(); - assert_eq!(app.files.len(), 1, "only a.txt is still dirty"); + assert_eq!(app.files().len(), 1, "only a.txt is still dirty"); assert!( - app.current < app.files.len(), + app.current < app.files().len(), "current must be clamped in-range, got {}", app.current ); - assert_eq!(app.files[app.current].path, "a.txt"); + assert_eq!(app.files()[app.current].path, "a.txt"); } #[test] @@ -2784,7 +3006,7 @@ mod tests { let mut app = app_from_fixture(&fixture); app.open_current(); - let files_before: Vec = app.files.iter().map(|f| f.path.clone()).collect(); + let files_before: Vec = app.files().iter().map(|f| f.path.clone()).collect(); // Corrupt the throwaway fixture repo's OWN `.git/HEAD` so `diff_uncommitted`'s // `repo.head()` call fails cheaply — never done against a real working tree. @@ -2793,7 +3015,7 @@ mod tests { app.refresh(); - let files_after: Vec = app.files.iter().map(|f| f.path.clone()).collect(); + let files_after: Vec = app.files().iter().map(|f| f.path.clone()).collect(); assert_eq!( files_after, files_before, "a failed refresh must leave existing state untouched" @@ -3698,4 +3920,165 @@ mod tests { "toggling layout cancels the selection" ); } + + // ── M5 CS1: the changeset-stack spine ───────────────────────────────────── + + #[test] + fn single_uncommitted_changeset_matches_m4_shape() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let app = app_from_fixture(&fixture); + assert_eq!( + app.changeset_count(), + 1, + "a non-Graphite repo degrades to a single synthetic changeset" + ); + assert_eq!(app.current_cs(), 0); + assert_eq!(app.base_label, "HEAD"); + assert!(matches!( + app.current_changeset().source, + ChangesetSource::Uncommitted + )); + } + + #[test] + fn committed_changeset_view_has_empty_sub_diffs_and_renders_read_only() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("a.txt", "one\n") + .create("first") + .unwrap(); + let head = fixture + .commit("main") + .file("a.txt", "one\nCHANGED\n") + .create("second") + .unwrap(); + + let repo = fixture.repo().unwrap(); + let cs = Changeset { + name: "main".to_string(), + source: ChangesetSource::Committed { base, head }, + title: None, + current: true, + needs_restack: false, + }; + let diff = crate::acquire::diff_changeset(repo, &cs).expect("diff_changeset"); + let view = ChangesetView::from_changeset_diff(cs, diff); + assert_eq!(view.file_count(), 1); + + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view]); + app.open_current(); + + assert_eq!(app.files().len(), 1); + assert_eq!( + app.effective_zoom_for(0), + EffectiveZoom::Single(Role::Combined), + "empty staged/unstaged sub-models collapse every zoom to combined-only, for free" + ); + + // Read-only follows from the natural collapse above: no committed-specific gate needed. + app.stage_hunk(); + let notice = app + .notice + .as_ref() + .expect("staging must refuse on a combined-only (committed) changeset"); + assert!(notice.text.contains("cycle zoom"), "got: {:?}", notice.text); + } + + #[test] + fn base_label_is_committed_changeset_base_short_sha() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("a.txt", "one\n") + .create("first") + .unwrap(); + let head = fixture + .commit("main") + .file("a.txt", "two\n") + .create("second") + .unwrap(); + + let repo = fixture.repo().unwrap(); + let cs = Changeset { + name: "main".to_string(), + source: ChangesetSource::Committed { base, head }, + title: None, + current: true, + needs_restack: false, + }; + let diff = crate::acquire::diff_changeset(repo, &cs).expect("diff_changeset"); + let view = ChangesetView::from_changeset_diff(cs, diff); + + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let app = App::from_changesets(owned, vec![view]); + + assert_eq!(app.base_label, base.to_string()[..7].to_string()); + } + + #[test] + fn current_cs_honors_lib_current_flag() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("a.txt", "one\n") + .create("first") + .unwrap(); + let head = fixture + .commit("main") + .file("a.txt", "two\n") + .create("second") + .unwrap(); + let repo = fixture.repo().unwrap(); + + // Deliberately NOT current — listed first, so a naive "open index 0" would pick it. + let not_current = Changeset { + name: "not-current".to_string(), + source: ChangesetSource::Committed { base, head: base }, + title: None, + current: false, + needs_restack: false, + }; + let current = Changeset { + name: "current".to_string(), + source: ChangesetSource::Committed { base, head }, + title: None, + current: true, + needs_restack: false, + }; + + let not_current_view = ChangesetView::from_changeset_diff( + not_current.clone(), + crate::acquire::diff_changeset(repo, ¬_current).unwrap(), + ); + let current_view = ChangesetView::from_changeset_diff( + current.clone(), + crate::acquire::diff_changeset(repo, ¤t).unwrap(), + ); + + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let app = App::from_changesets(owned, vec![not_current_view, current_view]); + + assert_eq!( + app.current_cs(), + 1, + "must open on the lib-marked current entry" + ); + assert_eq!(app.current_changeset().name, "current"); + } } diff --git a/git-workon-review/src/error.rs b/git-workon-review/src/error.rs index 9b1dee0..7fa8865 100644 --- a/git-workon-review/src/error.rs +++ b/git-workon-review/src/error.rs @@ -48,6 +48,13 @@ pub enum DiffError { #[source] source: git2::Error, }, + + /// [`workon::assemble_changesets`] failed to walk the stack (a broken Graphite metadata + /// snapshot, an unresolvable branch, etc.) — surfaced distinctly from + /// [`Self::ChangesetDiffFailed`], which is a resolved-but-undiffable rev pair. + #[error("failed to assemble the changeset stack")] + #[diagnostic(code(workon::review::stack_assembly_failed))] + StackAssembly(#[from] workon::WorkonError), } /// Errors synthesizing a [`crate::synthesis::PatchText`] from a [`crate::model::FileChange`]. diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index a021c6d..09635a5 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -3,8 +3,8 @@ mod tui; use clap::Parser; use git2::Repository; use miette::{IntoDiagnostic, Result}; -use workon_review::acquire::diff_uncommitted; -use workon_review::app::App; +use workon_review::acquire::{diff_changeset, resolve_changesets}; +use workon_review::app::{App, ChangesetView}; /// A TUI for reviewing changesets #[derive(Debug, Parser)] @@ -15,17 +15,33 @@ fn main() -> Result<()> { Cli::parse(); let repo = Repository::discover(".").into_diagnostic()?; - let diffs = diff_uncommitted(&repo).into_diagnostic()?; + let branch = repo + .head() + .into_diagnostic()? + .shorthand() + .into_diagnostic()? + .to_string(); + + // `resolve_changesets` is the M5 entry point (locked decision #7, auto-detect): the full + // Graphite stack when one is active, or a single synthetic uncommitted changeset otherwise + // — the latter keeps a non-Graphite repo byte-identical to M2–M4's `diff_uncommitted` path. + let changesets = resolve_changesets(&repo, &branch).into_diagnostic()?; + + let mut views = Vec::with_capacity(changesets.len()); + for cs in changesets { + let diff = diff_changeset(&repo, &cs).into_diagnostic()?; + views.push(ChangesetView::from_changeset_diff(cs, diff)); + } - if diffs.combined.files.is_empty() { + if views.len() == 1 && views[0].file_count() == 0 { eprintln!("nothing to review"); return Ok(()); } // `App` owns its own `Repository` handle (see `app.rs`'s doc comment) — moved in here after - // `diff_uncommitted` is done borrowing it. The whole `WorktreeDiffs` goes in: the file list is - // combined-driven, but the per-role zoom panes need the staged/unstaged sub-diffs too. - let mut app = App::new(repo, diffs); + // acquisition is done borrowing it. `App::from_changesets` opens on whichever changeset the + // lib marked `current` (locked decision #6). + let mut app = App::from_changesets(repo, views); app.open_current(); tui::run(&mut app).into_diagnostic()?; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 435884a..27076d0 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -348,8 +348,8 @@ pub fn render(frame: &mut Frame, app: &mut App) { fn render_header(frame: &mut Frame, app: &App, area: Rect) { let idx = app.current + 1; - let n = app.files.len(); - let label = match app.files.get(app.current) { + let n = app.files().len(); + let label = match app.files().get(app.current) { Some(f) if f.status == FileStatus::Renamed || f.status == FileStatus::Copied => { format!( "{} @ {} -> {}", @@ -424,14 +424,14 @@ fn render_gap_row( } fn render_body(frame: &mut Frame, app: &mut App, area: Rect) { - if app.files.is_empty() { + if app.files().is_empty() { frame.render_widget(Paragraph::new("(no changes)"), area); return; } let idx = app.current; - if app.files[idx].is_binary { - let msg = format!("[Binary file: {}]", app.files[idx].path); + if app.files()[idx].is_binary { + let msg = format!("[Binary file: {}]", app.files()[idx].path); frame.render_widget(Paragraph::new(msg).style(Style::default().fg(FG_DIM)), area); return; } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index ce5c467..d7b0953 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -483,8 +483,8 @@ mod tests { // A no-op refresh (nothing changed externally) still rebuilds the view in place; the // smoke test is simply that this doesn't panic and the file is still there. - assert_eq!(app.files.len(), 1); - assert_eq!(app.files[0].path, "a.txt"); + assert_eq!(app.files().len(), 1); + assert_eq!(app.files()[0].path, "a.txt"); } #[test] @@ -531,8 +531,8 @@ mod tests { let quit = update(&mut app, &mut pending, AppEvent::Tick); assert!(!quit, "Tick must never quit the loop"); - assert_eq!(app.files.len(), 1); - assert_eq!(app.files[0].path, "a.txt"); + assert_eq!(app.files().len(), 1); + assert_eq!(app.files()[0].path, "a.txt"); } #[test] From 4aa27c8485b92c5d97f63b5648e03b05d73472e1 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 11:22:07 -0400 Subject: [PATCH 038/203] feat(review): navigate changesets and mark committed ones --- git-workon-review/src/app.rs | 399 ++++++++++++++++++++++++++++---- git-workon-review/src/render.rs | 321 +++++++++++++++++++++++-- git-workon-review/src/tui.rs | 6 + 3 files changed, 668 insertions(+), 58 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index adc40a5..0395c7f 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -494,8 +494,8 @@ fn derive_scroll_value( /// [`DiffState::from_committed`]), which is enough on its own to render it read-only: the /// existing [`effective_zoom`] gate collapses `Split`/`Unstaged`/`Staged` to /// [`EffectiveZoom::Single(Role::Combined)`] whenever both sub-diffs are absent — no -/// committed-specific rendering code needed for M5's spine (mode-aware staging refusal/zoom -/// lock are `m5-changeset-nav`). +/// committed-specific rendering code needed for M5's spine (the mode-aware staging refusal and +/// zoom lock riding this natural collapse are [`App::is_committed`]'s targeted guards). pub struct ChangesetView { pub cs: Changeset, diff: DiffState, @@ -551,8 +551,10 @@ pub struct App { /// three `views_*` lazy caches) now lives on the ACTIVE entry's [`ChangesetView`]; read it /// through [`Self::cur`]/[`Self::cur_mut`] rather than indexing this directly. changesets: Vec, - /// Index into [`Self::changesets`] of the active changeset. M5 changeset-nav (`]c`/`[c`) - /// will move this; M5's spine (this changeset) only ever sets it at construction/refresh. + /// Index into [`Self::changesets`] of the active changeset. Moved by continuous file nav + /// crossing a changeset boundary ([`Self::next_file`]/[`Self::prev_file`]) and by explicit + /// changeset nav ([`Self::next_changeset`]/[`Self::prev_changeset`]/[`Self::goto_changeset`]), + /// besides construction/refresh. current_cs: usize, pub current: usize, /// Row index, in the ACTIVE layout's coordinate space, of the highlighted navigation @@ -805,7 +807,8 @@ impl App { /// Index into the reviewed stack of the active changeset — read by tests asserting the /// [`Self::from_changesets`]/[`Self::refresh`] "honor lib `current`" rule (locked decision - /// #6); changeset-nav (`]c`/`[c`) will add a setter in `m5-changeset-nav`. + /// #6), and by changeset-nav's own tests ([`Self::next_changeset`]/[`Self::prev_changeset`]/ + /// [`Self::goto_changeset`]). pub fn current_cs(&self) -> usize { self.current_cs } @@ -822,6 +825,15 @@ impl App { self.changesets.len() } + /// Whether the ACTIVE changeset is a committed range (`base..head`) rather than the + /// uncommitted worktree layer — derived from [`workon::ChangesetSource`] on every call rather + /// than cached (locked decision #2's "derive, don't store" mode gate). Drives every + /// committed-mode guard: the mode-aware staging refusal, skipping combined attribution (no + /// staged/unstaged sets exist to color by), and locking zoom to combined. + pub fn is_committed(&self) -> bool { + matches!(self.cur().cs.source, ChangesetSource::Committed { .. }) + } + /// Re-run [`crate::acquire::resolve_changesets`] against the CURRENT `HEAD` branch and /// rebuild every [`ChangesetView`] from scratch — the operation both a manual refresh (`r`) /// and (later) a post-staging-op/external-write refresh need. Re-assembling (not just @@ -1173,6 +1185,16 @@ impl App { /// persists across file navigation; both panes reset to their first hunks so `cursor`/`scroll` /// are always valid for the now-active view(s). pub fn cycle_zoom(&mut self) { + // A committed changeset has no staged/unstaged split to zoom into — lock zoom to combined + // (locked decision #2) rather than cycling into a state `effective_zoom` immediately + // collapses back anyway. + if self.is_committed() { + self.notify( + "changeset is committed — combined view only", + Severity::Info, + ); + return; + } self.zoom = match self.zoom { Zoom::Split => Zoom::Combined, Zoom::Combined => Zoom::Unstaged, @@ -1203,21 +1225,74 @@ impl App { self.derive_scroll(); } + /// Reshape onto changeset `target` (clamped into range), landing on file `file_idx` of ITS + /// list (clamped into range, `0` if empty) — the shared core of every changeset switch + /// (continuous file nav crossing a boundary, and `]c`/`[c`). A coordinate-space reshape + /// exactly like a plain file switch (cursor/scroll/selection reset via `open_current`), plus + /// re-deriving `base_label` for the newly active changeset (each changeset can have its own + /// base rev — see [`base_label_for`]). + fn switch_changeset(&mut self, target: usize, file_idx: usize) { + self.current_cs = target.min(self.changesets.len().saturating_sub(1)); + self.base_label = base_label_for(&self.cur().cs); + let n = self.cur().diff.files.len(); + self.current = if n == 0 { 0 } else { file_idx.min(n - 1) }; + self.open_current(); + } + + /// Advance to the next file (`]f`/Tab), continuously across the whole stack (locked decision + /// #5): at the active changeset's last file, this advances `current_cs` and lands on the NEXT + /// changeset's first file, rather than wrapping within the active changeset. Clamps (does NOT + /// wrap) at the very last file of the very last changeset. pub fn next_file(&mut self) { if self.cur().diff.files.is_empty() { return; } - self.current = (self.current + 1) % self.cur().diff.files.len(); - self.open_current(); + if self.current + 1 < self.cur().diff.files.len() { + self.current += 1; + self.open_current(); + } else if self.current_cs + 1 < self.changesets.len() { + self.switch_changeset(self.current_cs + 1, 0); + } + // Else: already at the stack's very last file — clamp, no-op. } + /// Retreat to the previous file (`[f`/BackTab), continuously across the whole stack — the + /// mirror of [`Self::next_file`]. At the active changeset's first file, drops into the + /// PREVIOUS changeset's LAST file. Clamps (does NOT wrap) at the very first file of the very + /// first changeset. pub fn prev_file(&mut self) { if self.cur().diff.files.is_empty() { return; } - self.current = - (self.current + self.cur().diff.files.len() - 1) % self.cur().diff.files.len(); - self.open_current(); + if self.current > 0 { + self.current -= 1; + self.open_current(); + } else if self.current_cs > 0 { + let target = self.current_cs - 1; + let last = self.changesets[target].diff.files.len().saturating_sub(1); + self.switch_changeset(target, last); + } + // Else: already at the stack's very first file — clamp, no-op. + } + + /// Jump to changeset `target`'s first file (`]c`/`[c`, and any future outline click-to-jump). + /// Clamps into `[0, changeset_count() - 1]` — never wraps. + pub fn goto_changeset(&mut self, target: usize) { + self.switch_changeset(target, 0); + } + + /// Jump to the next changeset's first file (`]c`). A no-op at the last changeset. + pub fn next_changeset(&mut self) { + if self.current_cs + 1 < self.changesets.len() { + self.goto_changeset(self.current_cs + 1); + } + } + + /// Jump to the previous changeset's first file (`[c`). A no-op at the first changeset. + pub fn prev_changeset(&mut self) { + if self.current_cs > 0 { + self.goto_changeset(self.current_cs - 1); + } } /// Row count of file `idx`'s `role` view in the active layout's space (0 if absent/unloaded). @@ -1438,6 +1513,27 @@ impl App { } } + /// Mode-aware refusal notice for a staging verb / line-selection start that only makes sense + /// outside the combined view — i.e. every call site below whose `staging_role()`/ + /// `staging_role().is_none()` guard failed (locked decision #2's "targeted guard"). A + /// committed changeset is ALWAYS combined-only (no staged/unstaged split exists to zoom + /// into — see [`Self::is_committed`]), so telling the user to "cycle zoom" there is actively + /// wrong; state the real reason instead. `verb` ("stage"/"select") keeps each call site's + /// original non-committed wording. + fn notify_combined_refusal(&mut self, verb: &str) { + if self.is_committed() { + self.notify( + "changeset is already committed — nothing to stage", + Severity::Error, + ); + } else { + self.notify( + format!("{verb} in the unstaged/staged pane — cycle zoom (z)"), + Severity::Error, + ); + } + } + /// Stage (unstaged pane) or unstage (staged pane) the hunk under the cursor (`s`). Refuses on /// the combined view, or when the cursor isn't in a hunk. /// @@ -1453,10 +1549,7 @@ impl App { return; } let Some(role) = self.staging_role() else { - self.notify( - "stage in the unstaged/staged pane — cycle zoom (z)", - Severity::Error, - ); + self.notify_combined_refusal("stage"); return; }; let Some(verb) = Self::verb_for_role(role) else { @@ -1481,10 +1574,7 @@ impl App { return; } let Some(role) = self.staging_role() else { - self.notify( - "stage in the unstaged/staged pane — cycle zoom (z)", - Severity::Error, - ); + self.notify_combined_refusal("stage"); return; }; let Some(verb) = Self::verb_for_role(role) else { @@ -1511,10 +1601,7 @@ impl App { return; } let Some(role) = self.staging_role() else { - self.notify( - "stage in the unstaged/staged pane — cycle zoom (z)", - Severity::Error, - ); + self.notify_combined_refusal("stage"); return; }; if role != Role::Unstaged { @@ -1541,10 +1628,7 @@ impl App { return; } let Some(role) = self.staging_role() else { - self.notify( - "stage in the unstaged/staged pane — cycle zoom (z)", - Severity::Error, - ); + self.notify_combined_refusal("stage"); return; }; if role != Role::Unstaged { @@ -1645,10 +1729,7 @@ impl App { return; } if self.staging_role().is_none() { - self.notify( - "select in the unstaged/staged pane — cycle zoom (z)", - Severity::Error, - ); + self.notify_combined_refusal("select"); return; } self.selection_anchor = Some(self.cursor); @@ -1768,10 +1849,7 @@ impl App { return; } let Some(role) = self.staging_role() else { - self.notify( - "stage in the unstaged/staged pane — cycle zoom (z)", - Severity::Error, - ); + self.notify_combined_refusal("stage"); return; }; let Some(verb) = Self::verb_for_role(role) else { @@ -1806,10 +1884,7 @@ impl App { return; } let Some(role) = self.staging_role() else { - self.notify( - "stage in the unstaged/staged pane — cycle zoom (z)", - Severity::Error, - ); + self.notify_combined_refusal("stage"); return; }; if role != Role::Unstaged { @@ -3985,13 +4060,20 @@ mod tests { "empty staged/unstaged sub-models collapse every zoom to combined-only, for free" ); - // Read-only follows from the natural collapse above: no committed-specific gate needed. + // Read-only follows from the natural collapse above; the refusal MESSAGE is + // committed-mode-aware (m5-changeset-nav locked decision #2) — a plain "already + // committed" notice, not the uncommitted "cycle zoom" hint (there's no zoom that would + // help here). app.stage_hunk(); let notice = app .notice .as_ref() .expect("staging must refuse on a combined-only (committed) changeset"); - assert!(notice.text.contains("cycle zoom"), "got: {:?}", notice.text); + assert!( + notice.text.contains("already committed"), + "got: {:?}", + notice.text + ); } #[test] @@ -4081,4 +4163,241 @@ mod tests { ); assert_eq!(app.current_changeset().name, "current"); } + + // ── M5 CS2: continuous nav, changeset nav, committed-mode guards ───────── + + /// A two-committed-changeset stack for CS2's nav tests, hand-built the same way as the M5 CS1 + /// tests above: `cs-a` (`root..mid`, TWO files — `a1.txt`/`a2.txt`) then `cs-b` (`mid..head`, + /// ONE file — `b1.txt`), opening on `cs-a`'s first file. The two-file first changeset lets a + /// test distinguish "advance within a changeset" from "cross into the next changeset" at its + /// boundary, rather than every `next_file` immediately crossing. + fn two_committed_changesets_two_and_one_files() -> App { + 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("a1.txt", "a1\n") + .file("a2.txt", "a2\n") + .create("mid") + .unwrap(); + let head = fixture + .commit("main") + .file("b1.txt", "b1\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + + let cs_a = Changeset { + name: "cs-a".to_string(), + source: ChangesetSource::Committed { + base: root, + head: mid, + }, + title: None, + current: true, + needs_restack: false, + }; + let cs_b = Changeset { + name: "cs-b".to_string(), + source: ChangesetSource::Committed { base: mid, head }, + title: None, + current: false, + 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_eq!(app.current_cs(), 0, "opens on cs-a (its current: true)"); + assert_eq!(app.files().len(), 2, "cs-a has two files"); + app + } + + #[test] + fn next_file_advances_within_a_changeset_before_crossing_into_the_next() { + let mut app = two_committed_changesets_two_and_one_files(); + + app.next_file(); + assert_eq!(app.current_cs(), 0, "still inside cs-a"); + assert_eq!(app.current, 1); + assert_eq!(app.files()[app.current].path, "a2.txt"); + + app.next_file(); + assert_eq!( + app.current_cs(), + 1, + "advancing past cs-a's last file crosses into cs-b" + ); + assert_eq!(app.current, 0, "lands on cs-b's FIRST file"); + assert_eq!(app.files()[0].path, "b1.txt"); + } + + #[test] + fn next_file_clamps_at_the_last_file_of_the_last_changeset() { + let mut app = two_committed_changesets_two_and_one_files(); + app.goto_changeset(1); + assert_eq!(app.current_cs(), 1); + assert_eq!(app.current, 0); + + app.next_file(); + assert_eq!( + app.current_cs(), + 1, + "the stack's very last file must clamp, not wrap to changeset 0" + ); + assert_eq!(app.current, 0); + } + + #[test] + fn prev_file_crosses_backward_into_the_previous_changesets_last_file() { + let mut app = two_committed_changesets_two_and_one_files(); + app.goto_changeset(1); + assert_eq!(app.current_cs(), 1); + assert_eq!(app.current, 0); + + app.prev_file(); + assert_eq!( + app.current_cs(), + 0, + "retreating past cs-b's first file crosses back into cs-a" + ); + assert_eq!(app.current, 1, "lands on cs-a's LAST file, not its first"); + assert_eq!(app.files()[1].path, "a2.txt"); + } + + #[test] + fn prev_file_clamps_at_the_first_file_of_the_first_changeset() { + let mut app = two_committed_changesets_two_and_one_files(); + assert_eq!(app.current_cs(), 0); + assert_eq!(app.current, 0); + + app.prev_file(); + assert_eq!( + app.current_cs(), + 0, + "the stack's very first file must clamp, not wrap to the last changeset" + ); + assert_eq!(app.current, 0); + } + + #[test] + fn bracket_c_jumps_to_the_adjacent_changesets_first_file() { + let mut app = two_committed_changesets_two_and_one_files(); + // Start mid-file so the jump is visibly to file 0, not just "whatever was current". + app.current = 1; + + app.next_changeset(); + assert_eq!(app.current_cs(), 1); + assert_eq!(app.current, 0, "]c always lands on the FIRST file"); + assert_eq!(app.files()[0].path, "b1.txt"); + + app.next_changeset(); + assert_eq!(app.current_cs(), 1, "]c clamps at the last changeset"); + + app.prev_changeset(); + assert_eq!(app.current_cs(), 0); + assert_eq!(app.current, 0); + + app.prev_changeset(); + assert_eq!(app.current_cs(), 0, "[c clamps at the first changeset"); + } + + #[test] + fn switching_changeset_updates_base_label() { + let mut app = two_committed_changesets_two_and_one_files(); + let cs_a_label = app.base_label.clone(); + + app.goto_changeset(1); + assert_ne!( + app.base_label, cs_a_label, + "cs-a and cs-b have different base revisions" + ); + } + + #[test] + fn is_committed_true_for_a_committed_changeset_false_for_uncommitted() { + let committed = two_committed_changesets_two_and_one_files(); + assert!(committed.is_committed()); + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + let uncommitted = app_from_fixture(&fixture); + assert!(!uncommitted.is_committed()); + } + + #[test] + fn cycle_zoom_is_a_no_op_on_a_committed_changeset() { + let mut app = two_committed_changesets_two_and_one_files(); + let zoom_before = app.zoom; + + app.cycle_zoom(); + + assert_eq!( + app.zoom, zoom_before, + "z must not change the requested zoom on a committed changeset" + ); + assert!( + app.notice.is_some(), + "z should still surface a notice explaining why it's a no-op" + ); + } + + #[test] + fn staging_verbs_refuse_with_a_committed_specific_message() { + let mut app = two_committed_changesets_two_and_one_files(); + + app.stage_file(); + let notice = app + .notice + .as_ref() + .expect("stage_file must refuse on a committed changeset"); + assert!( + notice.text.contains("already committed"), + "got: {:?}", + notice.text + ); + + app.clear_notice(); + app.discard_file(); + let notice = app + .notice + .as_ref() + .expect("discard_file must refuse on a committed changeset"); + assert!( + notice.text.contains("already committed"), + "got: {:?}", + notice.text + ); + + app.clear_notice(); + app.start_selection(); + assert!(app.selection_anchor.is_none()); + let notice = app + .notice + .as_ref() + .expect("start_selection must refuse on a committed changeset"); + assert!( + notice.text.contains("already committed"), + "got: {:?}", + notice.text + ); + } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 27076d0..18fc245 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -46,6 +46,9 @@ const BG_CURSOR: Color = Color::Rgb(45, 50, 90); /// row. The cursor row inside a selection keeps the cursor tint (cursor wins on its own row — see /// [`render_pane_sbs`]). const BG_SELECTION: Color = Color::Rgb(30, 66, 66); +/// Warning tone for the winbar's needs-restack marker (locked decision #9) — an amber, distinct +/// from [`FG_ERROR`]'s red: a stale-parent changeset is a heads-up to `gt restack`, not a failure. +const FG_WARN: Color = Color::Rgb(214, 158, 46); /// Blend the cursor row's tint into an existing background, so the cursor highlight composites /// with (rather than replaces) del/add/word-diff emphasis on the same row — the row highlight is @@ -176,7 +179,10 @@ enum AttributionMode<'a> { /// one file) and always correct even if the index changes between frames (the M4 watcher's /// concern, not this one's, but the cost of getting it wrong is a stale color). fn combined_attribution(app: &App, idx: usize, role: Role) -> Option { - if role != Role::Combined { + // A committed changeset's combined role is the whole `base..head` range, not a fusion of + // staged/unstaged sets — there's nothing to attribute (locked decision #2's "skip + // attribution" guard). Every cell renders as plain, undifferentiated change. + if role != Role::Combined || app.is_committed() { return None; } let unstaged = app.role_change(idx, Role::Unstaged); @@ -184,17 +190,16 @@ fn combined_attribution(app: &App, idx: usize, role: Role) -> Option) -> AttributionMode<'_> { - match role { - Role::Combined => AttributionMode::Attributed( - attribution - .as_ref() - .expect("combined_attribution always builds one for Role::Combined"), - ), - Role::Unstaged => AttributionMode::Plain, - Role::Staged => AttributionMode::StagedUniform, + match (role, attribution) { + (Role::Combined, Some(a)) => AttributionMode::Attributed(a), + (Role::Combined, None) => AttributionMode::Plain, + (Role::Unstaged, _) => AttributionMode::Plain, + (Role::Staged, _) => AttributionMode::StagedUniform, } } @@ -346,10 +351,11 @@ pub fn render(frame: &mut Frame, app: &mut App) { render_body(frame, app, body_area); } -fn render_header(frame: &mut Frame, app: &App, area: Rect) { - let idx = app.current + 1; - let n = app.files().len(); - let label = match app.files().get(app.current) { +/// 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). +fn current_file_label(app: &App) -> String { + match app.files().get(app.current) { Some(f) if f.status == FileStatus::Renamed || f.status == FileStatus::Copied => { format!( "{} @ {} -> {}", @@ -360,14 +366,60 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { } Some(f) => f.path.clone(), None => String::new(), - }; - let text = format!("[{idx}/{n}] {label}"); + } +} + +/// 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) { + if app.changeset_count() > 1 { + render_winbar(frame, app, area); + return; + } + let idx = app.current + 1; + let n = app.files().len(); + let text = format!("[{idx}/{n}] {}", current_file_label(app)); frame.render_widget( Paragraph::new(text).style(Style::default().add_modifier(Modifier::BOLD)), area, ); } +/// 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. +fn render_winbar(frame: &mut Frame, app: &App, area: Rect) { + let cs = app.current_changeset(); + let i = app.current_cs() + 1; + let n = app.changeset_count(); + let title = cs.title.as_deref().unwrap_or(cs.name.as_str()); + + let mut spans = vec![TSpan::styled( + format!("[{i}/{n}] {title}"), + Style::default().add_modifier(Modifier::BOLD), + )]; + // 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( + " ⚠ needs restack", + Style::default().fg(FG_WARN).add_modifier(Modifier::BOLD), + )); + } + let fidx = app.current + 1; + let nfiles = app.files().len(); + spans.push(TSpan::styled( + format!(" — {} ({fidx}/{nfiles})", current_file_label(app)), + Style::default().add_modifier(Modifier::BOLD), + )); + + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + /// Footer priority: a pending discard confirm's prompt (warn-toned) wins over a transient notice, /// which wins over the dim hint line. fn render_footer(frame: &mut Frame, app: &App, area: Rect) { @@ -390,7 +442,13 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ); } None => { - let text = "j/k scroll v select s/S stage d/D discard z zoom w focus q quit"; + // A committed changeset is locked to the combined view (locked decision #2) — `z` + // zoom and `w` split-focus have nothing to act on, so drop them from the hint. + let text = if app.is_committed() { + "j/k scroll v select s/S stage d/D discard q quit" + } else { + "j/k scroll v select s/S stage d/D discard z zoom w focus q quit" + }; frame.render_widget( Paragraph::new(text).style(Style::default().fg(FG_DIM)), area, @@ -1571,4 +1629,231 @@ mod tests { "the confirm prompt must take priority over the notice, got: {footer:?}" ); } + + // ── M5 CS2: winbar (locked decisions #8 + #9) ───────────────────────────── + + /// Build a two-committed-changeset stack for the winbar 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`). + fn two_committed_changesets_app(fixture: &Fixture) -> App { + use git2::Repository; + use workon::{Changeset, ChangesetSource}; + + use crate::app::ChangesetView; + + 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(), + source: ChangesetSource::Committed { + base: root, + head: mid, + }, + title: Some("Add a".to_string()), + current: false, + needs_restack: false, + }; + let cs_b = Changeset { + name: "cs-b".to_string(), + source: ChangesetSource::Committed { base: mid, head }, + title: None, + current: true, + needs_restack: true, + }; + + 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(); + app + } + + #[test] + fn winbar_shows_changeset_position_title_path_and_restack_marker() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + + 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("[2/2]"), + "expected the changeset position counter, got: {header:?}" + ); + assert!( + header.contains("cs-b"), + "expected the active changeset's name (no title set), got: {header:?}" + ); + assert!( + header.contains("needs restack"), + "expected the needs-restack marker, got: {header:?}" + ); + assert!( + header.contains("b.txt") && header.contains("(1/1)"), + "expected the active file's path and position, got: {header:?}" + ); + } + + #[test] + fn winbar_restack_marker_carries_the_warning_color() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + + 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(super::FG_WARN), + "expected the restack glyph to carry the warning color, not the plain header color" + ); + } + + #[test] + fn winbar_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(); + + 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("Add a"), + "expected the changeset's title, not its bare name, got: {header:?}" + ); + assert!( + !header.contains("needs restack"), + "cs-a is not stale, so no restack marker should show, got: {header:?}" + ); + } + + #[test] + fn winbar_absent_for_a_lone_changeset() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + 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(); + assert!( + header.contains("[1/1]"), + "a lone changeset keeps the M4 `[fidx/nfiles]` file counter, got: {header:?}" + ); + assert!( + !header.contains('⚠'), + "a lone changeset must not render the winbar 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, ChangesetSource}; + + 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") + .file("f.txt", head_content) + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + + let cs = Changeset { + name: "main".to_string(), + source: ChangesetSource::Committed { base, head }, + title: None, + current: true, + needs_restack: false, + }; + let diff = crate::acquire::diff_changeset(repo, &cs).unwrap(); + let view = ChangesetView::from_changeset_diff(cs, diff); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view]); + app.open_current(); + assert!(app.is_committed()); + // Park the cursor off the changed row so its highlight tint doesn't blend into the Add + // cell's background and muddy the color comparison below (same convention as + // `combined_view_colors_a_staged_change_dim_and_an_unstaged_change_bright`). + app.cursor = 0; + app.derive_scroll(); + + let buf = render_once(&mut app, 60, 20); + let content = buf_lines(&buf); + let row_y = content + .iter() + .position(|line| line.contains("new word here")) + .expect("new-side text visible") as u16; + + let left_w = (buf.area.width.saturating_sub(1)) / 2; + let new_content_x = left_w + 1 + 4; // divider + gutter width 3 + 1 space + let add_bg = buf.cell((new_content_x, row_y)).unwrap().style().bg; + + let bright_adds = [Some(BG_ADD_SUBTLE), Some(BG_ADD_STRONG)]; + let dim_adds = [Some(BG_ADD_STAGED_SUBTLE), Some(BG_ADD_STAGED_STRONG)]; + assert!( + bright_adds.contains(&add_bg), + "expected a committed changeset's Add cell to render the plain (bright) pair, \ + got {add_bg:?}" + ); + assert!( + !dim_adds.contains(&add_bg), + "a committed changeset has no staged/unstaged split to color by — it must never \ + render the dim 'already staged' pair, got {add_bg:?}" + ); + } } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index d7b0953..ed3d74e 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -61,6 +61,8 @@ enum Action { PrevFile, NextHunk, PrevHunk, + NextChangeset, + PrevChangeset, ToggleLayout, CycleZoom, ToggleSplitFocus, @@ -83,6 +85,8 @@ fn map_key(pending: &mut Option, key: KeyEvent, pane_height: usize) -> Act ('[', KeyCode::Char('f')) => Action::PrevFile, (']', KeyCode::Char('h')) => Action::NextHunk, ('[', KeyCode::Char('h')) => Action::PrevHunk, + (']', KeyCode::Char('c')) => Action::NextChangeset, + ('[', KeyCode::Char('c')) => Action::PrevChangeset, _ => Action::None, }; } @@ -133,6 +137,8 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::PrevFile => app.prev_file(), Action::NextHunk => app.next_hunk_row(), Action::PrevHunk => app.prev_hunk_row(), + Action::NextChangeset => app.next_changeset(), + Action::PrevChangeset => app.prev_changeset(), Action::ToggleLayout => app.toggle_layout(), Action::CycleZoom => app.cycle_zoom(), Action::ToggleSplitFocus => app.toggle_split_focus(), From b99497107aae764e3e845cb90d7ca82f868239e5 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 11:55:00 -0400 Subject: [PATCH 039/203] feat(review): add outline pane with flat and stack modes --- git-workon-review/src/app.rs | 583 ++++++++++++++++++++++++++++++- git-workon-review/src/lib.rs | 1 + git-workon-review/src/outline.rs | 337 ++++++++++++++++++ git-workon-review/src/render.rs | 288 ++++++++++++++- git-workon-review/src/tui.rs | 334 +++++++++++++++--- 5 files changed, 1488 insertions(+), 55 deletions(-) create mode 100644 git-workon-review/src/outline.rs diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 0395c7f..350dc17 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -21,6 +21,7 @@ use crate::apply::{Git2Applier, StageVerb}; use crate::highlight::{FgSpan, TsHighlighter}; use crate::model::{DiffModel, FileChange, FileStatus, Hunk, LineKind}; use crate::ops; +use crate::outline::{self, OutlineChangeset, OutlineFile, OutlineItem, OutlineMode}; use crate::queue::{OpOutcome, StagingOp, StagingQueue}; use crate::refresh::{IndexSignature, RefreshCoordinator}; use crate::stage_op::{FileStagingOp, LineSelectionOp}; @@ -439,6 +440,19 @@ pub fn effective_zoom( } } +/// The outline side pane's own state (locked fork 3): whether it's showing, whether IT (rather +/// than the diff) currently has keyboard focus, its own cursor (an index into +/// [`App::outline_items`]'s row list — a wholly separate coordinate space from [`App::cursor`]), +/// and which [`OutlineMode`] it's rendering. Lives directly on [`App`] (unlike the per-changeset +/// diff state) since it's small and there's only ever one outline for the whole review session. +#[derive(Debug, Clone)] +pub struct OutlineState { + pub open: bool, + pub focused: bool, + pub cursor: usize, + pub mode: OutlineMode, +} + /// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the /// staged role. Focus decides which pane owns [`App::cursor`]/[`App::scroll`] and where the cursor /// highlight draws; `w` toggles it. @@ -536,6 +550,25 @@ impl ChangesetView { pub fn file_count(&self) -> usize { self.diff.files.len() } + + /// This changeset's combined file list — `App::outline_items` reads this to build the + /// outline's rows without reaching into [`Self::diff`] directly (private to this module). + pub fn files(&self) -> &[FileChange] { + &self.diff.files + } + + /// This changeset's file `idx`'s [`crate::outline::StagedStatus`] for the outline's status + /// column, derived from the same unstaged/staged membership maps [`effective_zoom`] gates + /// on. A committed changeset's maps are always all-`None` (see + /// [`DiffState::from_committed`]), so this naturally resolves every one of its files to + /// [`crate::outline::StagedStatus::None`] with no committed-specific branch — the outline's + /// "status column only for the uncommitted changeset" requirement falls out of that, rather + /// than being checked explicitly here. + pub fn staged_status(&self, idx: usize) -> crate::outline::StagedStatus { + let has_unstaged = self.diff.unstaged_idx.get(idx).copied().flatten().is_some(); + let has_staged = self.diff.staged_idx.get(idx).copied().flatten().is_some(); + crate::outline::StagedStatus::from_flags(has_unstaged, has_staged) + } } /// Review session state: the active changeset's file list, per-file lazily loaded views, and @@ -620,6 +653,12 @@ pub struct App { /// synchronous poll-on-`Tick`, no threads). See [`Self::on_tick`] and /// [`Self::coordinated_refresh`]. refresh_coordinator: RefreshCoordinator, + /// The outline side pane's state — see [`OutlineState`]'s doc comment. Initialized by + /// [`Self::from_changesets`] to open-when-`len() > 1`/unfocused/[`OutlineMode::default`] + /// (the "decided without interview" default in the M5 plan), and repositioned (never + /// rebuilt-from-scratch — `open`/`focused`/`mode` persist, like [`Self::layout`]/ + /// [`Self::zoom`]) by every diff-initiated nav and by [`Self::refresh`]. + outline: OutlineState, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -707,6 +746,16 @@ impl App { ); let current_cs = current_cs_index(&changesets); let base_label = base_label_for(&changesets[current_cs].cs); + // Default-open when the stack has more than one changeset (the M5 plan's + // "decided without interview" default — preserves the M4 full-width look for a lone + // uncommitted changeset), unfocused (the diff keeps initial keyboard focus so the user + // can start reading immediately), Stack mode (shows the structure M5 exists to surface). + let outline = OutlineState { + open: changesets.len() > 1, + focused: false, + cursor: 0, + mode: OutlineMode::default(), + }; let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial // diff, so the FIRST `Tick` doesn't see an "unseen" signature and spuriously re-diff an @@ -720,7 +769,7 @@ impl App { let ticket = refresh_coordinator.begin(); refresh_coordinator.complete(ticket, sig); } - Self { + let mut app = Self { repo, changesets, current_cs, @@ -741,7 +790,14 @@ impl App { pending_confirm: None, selection_anchor: None, refresh_coordinator, - } + outline, + }; + // 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 + // than leaving it at its default `0` — in Stack mode row `0` is a HEADER, not the current + // file, whenever the current changeset isn't the first in the stack. + app.sync_outline_to_current(); + app } /// The current `.git/index`'s cheap fingerprint (mtime + size), or `None` if the read fails — @@ -917,6 +973,10 @@ impl App { .unwrap_or(if n == 0 { 0 } else { self.current.min(n - 1) }); self.open_current(); + // The rebuilt changeset list can resize/reorder the outline's row list out from under + // its cursor — reposition it, same as every other diff-initiated nav (does NOT touch + // `outline.open`/`focused`/`mode`, which persist across a refresh like `layout`/`zoom`). + self.sync_outline_to_current(); } /// Resolve the [`EffectiveZoom`] for file `idx` this frame: the requested [`Self::zoom`] gated @@ -1243,6 +1303,13 @@ impl App { /// #5): at the active changeset's last file, this advances `current_cs` and lands on the NEXT /// changeset's first file, rather than wrapping within the active changeset. Clamps (does NOT /// wrap) at the very last file of the very last changeset. + /// + /// A DIFF-initiated nav entry point (as opposed to `switch_changeset`/`goto_changeset`, which + /// also serve the OUTLINE's own jumps) — repositions the outline cursor to follow via + /// [`Self::sync_outline_to_current`] at the end. This is the sync-follow discipline's echo + /// break (see that method's doc comment): only the diff-initiated entry points call it, so an + /// outline-initiated jump (which sets [`OutlineState::cursor`] itself before calling + /// `switch_changeset`/`goto_changeset` directly) never re-triggers it. pub fn next_file(&mut self) { if self.cur().diff.files.is_empty() { return; @@ -1254,12 +1321,14 @@ impl App { self.switch_changeset(self.current_cs + 1, 0); } // Else: already at the stack's very last file — clamp, no-op. + self.sync_outline_to_current(); } /// Retreat to the previous file (`[f`/BackTab), continuously across the whole stack — the /// mirror of [`Self::next_file`]. At the active changeset's first file, drops into the /// PREVIOUS changeset's LAST file. Clamps (does NOT wrap) at the very first file of the very - /// first changeset. + /// first changeset. See [`Self::next_file`]'s doc comment for why this calls + /// [`Self::sync_outline_to_current`] at the end. pub fn prev_file(&mut self) { if self.cur().diff.files.is_empty() { return; @@ -1273,26 +1342,195 @@ impl App { self.switch_changeset(target, last); } // Else: already at the stack's very first file — clamp, no-op. + self.sync_outline_to_current(); } - /// Jump to changeset `target`'s first file (`]c`/`[c`, and any future outline click-to-jump). - /// Clamps into `[0, changeset_count() - 1]` — never wraps. + /// Jump to changeset `target`'s first file (`]c`/`[c`, and the outline's own header-row + /// jump). Clamps into `[0, changeset_count() - 1]` — never wraps. Deliberately does NOT call + /// [`Self::sync_outline_to_current`] itself (see [`Self::next_file`]'s doc comment) — the + /// outline's own header-row jump ([`Self::outline_confirm`]) calls it explicitly afterward + /// instead, since this method is shared with that outline-initiated path. pub fn goto_changeset(&mut self, target: usize) { self.switch_changeset(target, 0); } - /// Jump to the next changeset's first file (`]c`). A no-op at the last changeset. + /// Jump to the next changeset's first file (`]c`). A no-op at the last changeset. A + /// DIFF-initiated entry point — see [`Self::next_file`]'s doc comment on the sync-follow + /// discipline. pub fn next_changeset(&mut self) { if self.current_cs + 1 < self.changesets.len() { self.goto_changeset(self.current_cs + 1); } + self.sync_outline_to_current(); } - /// Jump to the previous changeset's first file (`[c`). A no-op at the first changeset. + /// Jump to the previous changeset's first file (`[c`). A no-op at the first changeset. See + /// [`Self::next_file`]'s doc comment on the sync-follow discipline. pub fn prev_changeset(&mut self) { if self.current_cs > 0 { self.goto_changeset(self.current_cs - 1); } + self.sync_outline_to_current(); + } + + // ── Outline side pane (CS3) ───────────────────────────────────────────────── + + /// Snapshot every reviewed changeset into [`OutlineChangeset`]/[`OutlineFile`] and build the + /// current [`OutlineMode`]'s row list — the outline cursor's index space, and the source of + /// truth `render.rs` draws from. Rebuilt fresh on every call (cheap: a small stack times a + /// handful of files each, no caching, same posture as [`Self::effective_zoom_for`]) rather + /// than cached on `App`, so it's never stale across a mode toggle, a nav, or a refresh. + pub fn outline_items(&self) -> Vec { + let snapshot: Vec = self + .changesets + .iter() + .map(|v| OutlineChangeset { + label: v.cs.title.clone().unwrap_or_else(|| v.cs.name.clone()), + current: v.cs.current, + needs_restack: v.cs.needs_restack, + files: v + .files() + .iter() + .enumerate() + .map(|(idx, f)| OutlineFile { + path: f.path.clone(), + status: v.staged_status(idx), + }) + .collect(), + }) + .collect(); + outline::build_items(&snapshot, self.outline.mode) + } + + pub fn outline_open(&self) -> bool { + self.outline.open + } + + pub fn outline_focused(&self) -> bool { + self.outline.focused + } + + pub fn outline_cursor(&self) -> usize { + self.outline.cursor + } + + pub fn outline_mode(&self) -> OutlineMode { + self.outline.mode + } + + /// `o`: a three-state cycle — closed -> open+focused -> open+unfocused (focus back on the + /// diff, pane stays visible) -> closed. Opening always grabs focus (per the locked design); + /// the middle -> closed transition ("o while the outline is open but the diff has focus + /// closes it") isn't explicitly specified in the plan but is the natural completion of the + /// cycle, kept simple rather than adding a separate "close" key. + pub fn toggle_outline(&mut self) { + if !self.outline.open { + self.outline.open = true; + self.outline.focused = true; + self.sync_outline_to_current(); + } else if self.outline.focused { + self.outline.focused = false; + } else { + self.outline.open = false; + } + } + + /// Return focus to the diff without closing the outline (`Esc` while the outline has focus — + /// `tui::update` routes it here instead of quitting, per the locked design's "Esc must still + /// not quit when the outline has focus"). + pub fn outline_unfocus(&mut self) { + self.outline.focused = false; + } + + /// `i` while the outline has focus: cycle [`OutlineMode`], then reposition the cursor onto + /// the row matching the current diff position in the NEW mode's row list (the row layout + /// just changed shape, so the raw index would otherwise point at an unrelated row). + pub fn outline_cycle_mode(&mut self) { + self.outline.mode = self.outline.mode.cycle(); + self.sync_outline_to_current(); + } + + /// Move the outline's own cursor by `delta` rows (`j`/`k` while the outline has focus), + /// clamped into the current row list. Landing on a FILE row jumps the diff there + /// immediately (outline -> diff, per the locked design); landing on a HEADER row does NOT + /// jump — only [`Self::outline_confirm`] (`Enter`) jumps from a header, since a header's + /// "first file" isn't necessarily where a `j`/`k` scan through the stack should keep + /// stopping the diff. This calls [`Self::switch_changeset`] directly (not `next_file`/ + /// `goto_changeset`), so it does NOT re-trigger [`Self::sync_outline_to_current`] — see that + /// method's doc comment for why only the DIFF-initiated entry points do. + pub fn outline_move_by(&mut self, delta: i64) { + let items = self.outline_items(); + if items.is_empty() { + self.outline.cursor = 0; + return; + } + let max = (items.len() - 1) as i64; + let cur = self.outline.cursor as i64; + let new_idx = (cur + delta).clamp(0, max) as usize; + self.outline.cursor = new_idx; + if let OutlineItem::File { + cs_idx, file_idx, .. + } = &items[new_idx] + { + self.switch_changeset(*cs_idx, *file_idx); + } + } + + /// `Enter` while the outline has focus: jump the diff to the row under the outline cursor (a + /// file row jumps straight there; a header row jumps to that changeset's first file — the + /// one case [`Self::outline_move_by`] deliberately does NOT do on a bare cursor move), then + /// return focus to the diff. + pub fn outline_confirm(&mut self) { + let items = self.outline_items(); + match items.get(self.outline.cursor) { + Some(OutlineItem::File { + cs_idx, file_idx, .. + }) => self.switch_changeset(*cs_idx, *file_idx), + Some(OutlineItem::Header { cs_idx, .. }) => { + let cs_idx = *cs_idx; + self.goto_changeset(cs_idx); + // `goto_changeset` is the shared outline/diff core and deliberately does not + // self-sync (see its doc comment) — this outline-initiated call syncs explicitly + // so the cursor follows off the header row onto the file it just jumped to. + self.sync_outline_to_current(); + } + None => {} + } + self.outline.focused = false; + } + + /// Reposition (never rebuild/refocus) the outline cursor onto the row matching the CURRENT + /// diff changeset+file, or clamp it into bounds if no such row exists (e.g. Flat mode + /// deduped the current file's changeset out of the list). The sync-follow discipline's echo + /// break: called ONLY from the diff-initiated nav entry points (`next_file`/`prev_file`/ + /// `next_changeset`/`prev_changeset`/`refresh`, plus the two outline actions that explicitly + /// opt in after a header jump) — never from `switch_changeset`/`goto_changeset` themselves, + /// since those are the shared core an OUTLINE-initiated jump also calls, and an + /// outline-initiated jump has already set [`OutlineState::cursor`] to the row the user + /// selected. If this ran unconditionally inside `switch_changeset`, an outline `j`/`k` move + /// past a HEADER row (which never calls `switch_changeset`, so nothing would resync) would + /// be fine, but any accidental future call site wired into the shared core would instantly + /// stomp a manually-positioned outline cursor back onto the diff's last position — the exact + /// oscillation the prototype's `_suppress_sync` flag existed to prevent. Keeping the sync + /// calls only at the diff-facing entry points achieves the same break without needing a + /// mutable suppression flag on `App`. + fn sync_outline_to_current(&mut self) { + let items = self.outline_items(); + if items.is_empty() { + self.outline.cursor = 0; + return; + } + if let Some(idx) = items.iter().position(|it| { + matches!( + it, + OutlineItem::File { cs_idx, file_idx, .. } + if *cs_idx == self.current_cs && *file_idx == self.current + ) + }) { + self.outline.cursor = idx; + } else { + self.outline.cursor = self.outline.cursor.min(items.len() - 1); + } } /// Row count of file `idx`'s `role` view in the active layout's space (0 if absent/unloaded). @@ -2173,6 +2411,7 @@ mod tests { use super::{find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, EffectiveZoom, Role}; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::model::FileStatus; + use crate::outline::{OutlineItem, OutlineMode, StagedStatus}; #[test] fn combined_files_arrive_path_sorted() { @@ -4400,4 +4639,334 @@ mod tests { notice.text ); } + + // ── M5 CS3: outline side pane ─────────────────────────────────────────────── + + /// A committed changeset (`base..head`, one file, not current) beneath an uncommitted + /// changeset (one untracked file, current) — the mix the outline's "status column only for + /// the uncommitted changeset" test needs, hand-built the same way as every other M5 test in + /// this module (`Changeset` literal + `diff_changeset` + `ChangesetView::from_changeset_diff` + /// for BOTH sources — the acquisition router handles either). + fn committed_and_uncommitted_stack() -> App { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("base.txt", "b\n") + .create("base") + .unwrap(); + let head = fixture + .commit("main") + .file("c1.txt", "c1\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + std::fs::write(repo.workdir().unwrap().join("u1.txt"), "u1\n").unwrap(); + + let committed = Changeset { + name: "committed".to_string(), + source: ChangesetSource::Committed { base, head }, + title: Some("Committed work".to_string()), + current: false, + needs_restack: false, + }; + let uncommitted = Changeset { + name: "uncommitted".to_string(), + source: ChangesetSource::Uncommitted, + title: None, + current: true, + needs_restack: false, + }; + let view_c = ChangesetView::from_changeset_diff( + committed.clone(), + crate::acquire::diff_changeset(repo, &committed).unwrap(), + ); + let view_u = ChangesetView::from_changeset_diff( + uncommitted.clone(), + crate::acquire::diff_changeset(repo, &uncommitted).unwrap(), + ); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view_c, view_u]); + app.open_current(); + assert_eq!( + app.current_cs(), + 1, + "opens on the uncommitted layer (its current: true)" + ); + app + } + + #[test] + fn outline_default_open_for_a_multi_changeset_stack_closed_for_a_lone_changeset() { + let multi = two_committed_changesets_two_and_one_files(); + assert!( + multi.outline_open(), + "a stack of more than one changeset must default-open the outline" + ); + assert!( + !multi.outline_focused(), + "the diff keeps initial focus even though the outline defaults open" + ); + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + let lone = app_from_fixture(&fixture); + assert!( + !lone.outline_open(), + "a lone uncommitted changeset must keep the M4 full-width look (outline closed)" + ); + } + + #[test] + fn toggle_outline_cycles_closed_open_focused_open_unfocused_closed() { + let mut app = two_committed_changesets_two_and_one_files(); + // Force a known starting state regardless of the default. + while app.outline_open() { + app.toggle_outline(); + } + assert!(!app.outline_open()); + + app.toggle_outline(); + assert!( + app.outline_open() && app.outline_focused(), + "opening focuses" + ); + + app.toggle_outline(); + assert!( + app.outline_open() && !app.outline_focused(), + "toggling while focused returns focus to the diff without closing" + ); + + app.toggle_outline(); + assert!( + !app.outline_open(), + "toggling again while open-but-unfocused closes the pane" + ); + } + + #[test] + fn outline_cycle_mode_switches_between_flat_and_stack() { + let mut app = two_committed_changesets_two_and_one_files(); + let start = app.outline_mode(); + + app.outline_cycle_mode(); + assert_ne!(app.outline_mode(), start); + + app.outline_cycle_mode(); + assert_eq!( + app.outline_mode(), + start, + "cycling twice returns to the start" + ); + } + + #[test] + fn stack_mode_outline_items_carry_current_and_restack_markers() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let root = fixture + .commit("main") + .file("r.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(), + source: ChangesetSource::Committed { + base: root, + head: mid, + }, + title: None, + current: false, + needs_restack: false, + }; + let cs_b = Changeset { + name: "cs-b".to_string(), + source: ChangesetSource::Committed { base: mid, head }, + title: None, + current: true, + needs_restack: true, + }; + 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.outline.mode = OutlineMode::Stack; + + let items = app.outline_items(); + assert_eq!( + items[0], + OutlineItem::Header { + cs_idx: 0, + label: "cs-a".to_string(), + current: false, + needs_restack: false, + } + ); + let header_b = items + .iter() + .find(|it| matches!(it, OutlineItem::Header { cs_idx: 1, .. })) + .expect("cs-b header present"); + assert_eq!( + header_b, + &OutlineItem::Header { + cs_idx: 1, + label: "cs-b".to_string(), + current: true, + needs_restack: true, + } + ); + } + + #[test] + fn staged_status_column_only_populated_for_the_uncommitted_changesets_files() { + let mut app = committed_and_uncommitted_stack(); + app.outline.mode = OutlineMode::Stack; + let items = app.outline_items(); + + let committed_file = items + .iter() + .find(|it| matches!(it, OutlineItem::File { path, .. } if path == "c1.txt")) + .expect("committed changeset's file row present"); + assert_eq!( + committed_file, + &OutlineItem::File { + cs_idx: 0, + file_idx: 0, + path: "c1.txt".to_string(), + status: StagedStatus::None, + }, + "a committed changeset's file must carry no staged-ness status" + ); + + let uncommitted_file = items + .iter() + .find(|it| matches!(it, OutlineItem::File { path, .. } if path == "u1.txt")) + .expect("uncommitted changeset's file row present"); + assert!( + !matches!(uncommitted_file, OutlineItem::File { status: StagedStatus::None, .. }), + "the untracked uncommitted file must carry a real staged-ness status, got: {uncommitted_file:?}" + ); + } + + #[test] + fn outline_move_by_on_a_file_row_jumps_the_diff() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Flat; + app.outline.cursor = 0; + assert_eq!(app.current_cs(), 0); + assert_eq!(app.current, 0); + + // Flat mode: a1.txt, a2.txt, b1.txt — moving to index 2 must land the diff on b1.txt in + // cs-b. + app.outline_move_by(2); + assert_eq!( + app.current_cs(), + 1, + "the outline jump must switch changeset" + ); + assert_eq!(app.files()[app.current].path, "b1.txt"); + } + + #[test] + fn outline_move_by_on_a_header_row_does_not_jump_the_diff() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.cursor = 0; // cs-a's header row + let cs_before = app.current_cs(); + let file_before = app.current; + + // Header rows sit at indices 0 (cs-a) and 3 (cs-b) in Stack mode (header, a1, a2, + // header). Move onto the cs-b header without landing on a file row in between. + app.outline_move_by(3); + assert_eq!( + (app.current_cs(), app.current), + (cs_before, file_before), + "landing the outline cursor on a header row must not move the diff" + ); + } + + #[test] + fn outline_confirm_on_a_header_row_jumps_to_its_first_file_and_returns_focus() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.open = true; + app.outline.focused = true; + app.outline.cursor = 3; // cs-b's header row + + app.outline_confirm(); + + assert_eq!( + app.current_cs(), + 1, + "Enter on a header must jump to that changeset" + ); + assert_eq!(app.current, 0, "...landing on its FIRST file"); + assert!( + !app.outline_focused(), + "confirming returns focus to the diff" + ); + } + + #[test] + fn diff_initiated_nav_syncs_the_outline_cursor_without_stealing_focus() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + assert!(!app.outline_focused(), "diff keeps focus at construction"); + + app.next_changeset(); + assert!( + !app.outline_focused(), + "a diff-initiated nav must never steal focus from the diff to the outline" + ); + let items = app.outline_items(); + assert_eq!( + items[app.outline_cursor()], + OutlineItem::File { + cs_idx: 1, + file_idx: 0, + path: "b1.txt".to_string(), + status: StagedStatus::None, + }, + "the outline cursor must follow the diff's new position" + ); + } + + #[test] + fn closing_the_outline_restores_full_width_diff_rendering() { + // A render-level assertion belongs in render.rs's own tests; this just pins the state + // contract `render::render` reads (`outline_open`), so a regression there is caught at + // the state layer too. + let mut app = two_committed_changesets_two_and_one_files(); + // Default state is open+unfocused (locked design), so a single `o` here hits the + // "open, diff has focus" branch of the cycle, which closes the pane. + assert!(app.outline_open() && !app.outline_focused()); + app.toggle_outline(); + assert!(!app.outline_open()); + } } diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 28b2de9..0a62bc4 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -22,6 +22,7 @@ pub mod file_ops; pub mod highlight; pub mod model; pub mod ops; +pub mod outline; pub mod queue; pub mod refresh; pub mod render; diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs new file mode 100644 index 0000000..1a09c03 --- /dev/null +++ b/git-workon-review/src/outline.rs @@ -0,0 +1,337 @@ +//! The outline side pane's pure item model: given a snapshot of every reviewed changeset (label, +//! current/needs-restack flags, and per-file staged-ness), build the flat row list the pane +//! renders and the outline cursor indexes — no [`crate::app::App`]/[`crate::app::ChangesetView`] +//! dependency, mirroring how [`crate::attribute`] stays a pure module consumed by `app`/`render`. +//! +//! CS3 ships two of the eventual four modes ([`OutlineMode::Flat`]/[`OutlineMode::Stack`]); the +//! two path-trie modes (tree / stack-tree) are CS4's addition to this same module. + +/// Which of the outline's row-building strategies is active — cycled by `i` (only while the +/// outline pane has focus; see `App::outline_cycle_mode`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OutlineMode { + /// Every changed path across the whole stack, once each, no changeset headers. + Flat, + /// A changeset header row per changeset, followed by that changeset's file rows — the + /// default (locked choice for CS3: this is the mode that actually shows the stack + /// structure M5 exists to surface). + #[default] + Stack, +} + +impl OutlineMode { + /// `i`'s cycle order: `Flat -> Stack -> Flat`. CS4 will extend this to all four modes. + pub fn cycle(self) -> Self { + match self { + OutlineMode::Flat => OutlineMode::Stack, + OutlineMode::Stack => OutlineMode::Flat, + } + } +} + +/// A file's staged-ness for the outline's status column — a minimal indicator (locked CS3 +/// scope: NOT the prototype's X/Y two-column git-status matrix). Only meaningful for the +/// uncommitted changeset's files; a committed changeset's files always resolve to `None` +/// because their `unstaged_idx`/`staged_idx` maps are always-empty (see +/// `DiffState::from_committed`) — the same "derive, don't special-case" collapse +/// `effective_zoom` already relies on, so no committed-specific branch is needed here either. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StagedStatus { + /// No staged/unstaged sub-diff info for this file (a committed changeset's file, or an + /// uncommitted file that — impossibly — has a combined change but neither sub-change). + #[default] + None, + /// The file has an unstaged (index ↔ worktree) change but no staged one. + Unstaged, + /// The file has a staged (`HEAD` ↔ index) change but no unstaged one. + Staged, + /// The file has both — partially staged. + Partial, +} + +impl StagedStatus { + /// Resolve from the two membership flags `App`/`ChangesetView` already compute (does this + /// file have an entry in the unstaged/staged sub-`DiffModel`) — the same two booleans + /// [`crate::app::effective_zoom`] gates on. + pub fn from_flags(has_unstaged: bool, has_staged: bool) -> Self { + match (has_unstaged, has_staged) { + (true, true) => StagedStatus::Partial, + (true, false) => StagedStatus::Unstaged, + (false, true) => StagedStatus::Staged, + (false, false) => StagedStatus::None, + } + } + + /// The single-character glyph the outline renders in the status column, or a blank space + /// for [`StagedStatus::None`] (keeps every file row's path starting at the same column + /// regardless of whether it carries a status). + pub fn glyph(self) -> char { + match self { + StagedStatus::None => ' ', + StagedStatus::Unstaged => '+', + StagedStatus::Staged => '\u{2713}', // ✓ + StagedStatus::Partial => '\u{25D0}', // ◐ + } + } +} + +/// One file's outline-relevant data, as extracted from its owning changeset by +/// `App::outline_items` — the input [`build_items`] consumes. +#[derive(Debug, Clone)] +pub struct OutlineFile { + pub path: String, + pub status: StagedStatus, +} + +/// One changeset's outline-relevant data — a snapshot, not a borrow, so this module never needs +/// to know about [`crate::app::ChangesetView`] or `workon::Changeset` at all. +#[derive(Debug, Clone)] +pub struct OutlineChangeset { + /// The changeset's title, falling back to its name — same rule the winbar (render.rs) + /// already uses. + pub label: String, + /// Mirrors `workon::Changeset::current` — drives the outline's green current marker. + pub current: bool, + /// Mirrors `workon::Changeset::needs_restack` — drives the outline's amber warning glyph. + pub needs_restack: bool, + pub files: Vec, +} + +/// One row the outline pane renders and the outline cursor can land on. `cs_idx` is always the +/// index into `App`'s changeset list the row belongs to; `file_idx` (on [`Self::File`]) is the +/// index into THAT changeset's file list — together they're exactly what +/// `App::switch_changeset`/`App::goto_changeset` need to jump the diff there. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutlineItem { + /// A changeset header — only emitted in [`OutlineMode::Stack`]. + Header { + cs_idx: usize, + label: String, + current: bool, + needs_restack: bool, + }, + /// A file row — the target of every outline->diff jump. + File { + cs_idx: usize, + file_idx: usize, + path: String, + status: StagedStatus, + }, +} + +/// Build the outline's row list for `mode` from every reviewed changeset, in the same base -> +/// head order `App::changesets` holds them. +pub fn build_items(changesets: &[OutlineChangeset], mode: OutlineMode) -> Vec { + match mode { + OutlineMode::Flat => build_flat(changesets), + OutlineMode::Stack => build_stack(changesets), + } +} + +/// [`OutlineMode::Stack`]: a header per changeset, then its files in order — no de-duplication, +/// every changeset's own copy of a path (if touched more than once across the stack) gets its +/// own row under its own header. +fn build_stack(changesets: &[OutlineChangeset]) -> Vec { + let mut items = Vec::new(); + for (cs_idx, cs) in changesets.iter().enumerate() { + items.push(OutlineItem::Header { + cs_idx, + label: cs.label.clone(), + current: cs.current, + needs_restack: cs.needs_restack, + }); + for (file_idx, file) in cs.files.iter().enumerate() { + items.push(OutlineItem::File { + cs_idx, + file_idx, + path: file.path.clone(), + status: file.status, + }); + } + } + items +} + +/// [`OutlineMode::Flat`]: every changed path once, in FIRST-appearance order (a stable, readable +/// order that doesn't reshuffle just because a later changeset re-touches an earlier path), but +/// pointing at its LAST (newest / closest-to-head) occurrence — "last-write-wins" per the locked +/// design: a path touched by both an earlier committed changeset and the uncommitted layer +/// should jump to (and show the staged-ness of) the uncommitted layer's copy, not the stale +/// committed one. +fn build_flat(changesets: &[OutlineChangeset]) -> Vec { + let mut order: Vec = Vec::new(); + let mut latest: std::collections::HashMap = + std::collections::HashMap::new(); + for (cs_idx, cs) in changesets.iter().enumerate() { + for (file_idx, file) in cs.files.iter().enumerate() { + if !latest.contains_key(&file.path) { + order.push(file.path.clone()); + } + latest.insert(file.path.clone(), (cs_idx, file_idx, file.status)); + } + } + order + .into_iter() + .map(|path| { + let (cs_idx, file_idx, status) = latest[&path]; + OutlineItem::File { + cs_idx, + file_idx, + path, + status, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cs( + label: &str, + current: bool, + needs_restack: bool, + files: &[(&str, StagedStatus)], + ) -> OutlineChangeset { + OutlineChangeset { + label: label.to_string(), + current, + needs_restack, + files: files + .iter() + .map(|(p, s)| OutlineFile { + path: p.to_string(), + status: *s, + }) + .collect(), + } + } + + #[test] + fn stack_mode_emits_a_header_before_each_changesets_files() { + let changesets = vec![ + cs("cs-a", false, false, &[("a1.txt", StagedStatus::None)]), + cs("cs-b", true, true, &[("b1.txt", StagedStatus::None)]), + ]; + let items = build_items(&changesets, OutlineMode::Stack); + assert_eq!( + items, + vec![ + OutlineItem::Header { + cs_idx: 0, + label: "cs-a".to_string(), + current: false, + needs_restack: false, + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 0, + path: "a1.txt".to_string(), + status: StagedStatus::None, + }, + OutlineItem::Header { + cs_idx: 1, + label: "cs-b".to_string(), + current: true, + needs_restack: true, + }, + OutlineItem::File { + cs_idx: 1, + file_idx: 0, + path: "b1.txt".to_string(), + status: StagedStatus::None, + }, + ] + ); + } + + #[test] + fn flat_mode_has_no_headers() { + let changesets = vec![cs( + "cs-a", + true, + false, + &[ + ("a1.txt", StagedStatus::None), + ("a2.txt", StagedStatus::None), + ], + )]; + let items = build_items(&changesets, OutlineMode::Flat); + assert!(items + .iter() + .all(|it| matches!(it, OutlineItem::File { .. }))); + assert_eq!(items.len(), 2); + } + + #[test] + fn flat_mode_dedupes_a_shared_path_to_the_newest_changesets_occurrence() { + let changesets = vec![ + cs("cs-a", false, false, &[("shared.txt", StagedStatus::None)]), + cs( + "cs-b", + true, + false, + &[("shared.txt", StagedStatus::Unstaged)], + ), + ]; + let items = build_items(&changesets, OutlineMode::Flat); + assert_eq!(items.len(), 1, "the shared path must appear exactly once"); + assert_eq!( + items[0], + OutlineItem::File { + cs_idx: 1, + file_idx: 0, + path: "shared.txt".to_string(), + status: StagedStatus::Unstaged, + }, + "must point at cs-b (the LATER/newer changeset), not cs-a" + ); + } + + #[test] + fn flat_mode_preserves_first_appearance_order_despite_last_write_wins_target() { + let changesets = vec![ + cs( + "cs-a", + false, + false, + &[ + ("first.txt", StagedStatus::None), + ("shared.txt", StagedStatus::None), + ], + ), + cs("cs-b", true, false, &[("shared.txt", StagedStatus::Staged)]), + ]; + let items = build_items(&changesets, OutlineMode::Flat); + let paths: Vec<&str> = items + .iter() + .map(|it| match it { + OutlineItem::File { path, .. } => path.as_str(), + OutlineItem::Header { .. } => unreachable!(), + }) + .collect(); + assert_eq!( + paths, + vec!["first.txt", "shared.txt"], + "display order follows first appearance, not the retargeted changeset" + ); + } + + #[test] + fn staged_status_from_flags_covers_the_truth_table() { + assert_eq!(StagedStatus::from_flags(false, false), StagedStatus::None); + assert_eq!( + StagedStatus::from_flags(true, false), + StagedStatus::Unstaged + ); + assert_eq!(StagedStatus::from_flags(false, true), StagedStatus::Staged); + assert_eq!(StagedStatus::from_flags(true, true), StagedStatus::Partial); + } + + #[test] + fn mode_cycles_between_flat_and_stack() { + assert_eq!(OutlineMode::Flat.cycle(), OutlineMode::Stack); + assert_eq!(OutlineMode::Stack.cycle(), OutlineMode::Flat); + } +} diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 18fc245..7cc1b9d 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -17,6 +17,7 @@ use crate::app::{App, EffectiveZoom, FileView, Layout as AppLayout, Notice, Role use crate::attribute::Attribution; use crate::highlight::FgSpan; use crate::model::FileStatus; +use crate::outline::OutlineItem; use crate::wordiff::Span as WordSpan; const BG_DEL_SUBTLE: Color = Color::Rgb(60, 24, 24); @@ -49,6 +50,16 @@ const BG_SELECTION: Color = Color::Rgb(30, 66, 66); /// Warning tone for the winbar's needs-restack marker (locked decision #9) — an amber, distinct /// from [`FG_ERROR`]'s red: a stale-parent changeset is a heads-up to `gt restack`, not a failure. const FG_WARN: Color = Color::Rgb(214, 158, 46); +/// Tone for the outline's "this is the lib-marked `current` changeset" marker (locked decision +/// #9's outline half) — a green, distinct from every other marker color in this module so +/// "current" reads unambiguously at a glance. +const FG_CURRENT: Color = Color::Rgb(96, 200, 128); +/// Cursor tint for the outline pane while it is OPEN but NOT focused — a dimmer wash than +/// [`BG_CURSOR`] so the outline's remembered position stays legible without competing with the +/// diff's own (focused) cursor row for visual weight. +const BG_OUTLINE_CURSOR_UNFOCUSED: Color = Color::Rgb(35, 38, 55); +/// Fixed column width of the outline side pane (locked design: "~35 cols"). +const OUTLINE_WIDTH: u16 = 35; /// Blend the cursor row's tint into an existing background, so the cursor highlight composites /// with (rather than replaces) del/add/word-diff emphasis on the same row — the row highlight is @@ -348,7 +359,105 @@ pub fn render(frame: &mut Frame, app: &mut App) { render_header(frame, app, header_area); render_footer(frame, app, footer_area); - render_body(frame, app, body_area); + + if app.outline_open() { + let hlayout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(OUTLINE_WIDTH), + Constraint::Length(1), + Constraint::Min(1), + ]) + .split(body_area); + let outline_area = hlayout[0]; + let div_area = hlayout[1]; + let diff_area = hlayout[2]; + render_outline(frame, app, outline_area); + for y in div_area.y..div_area.y + div_area.height { + frame + .buffer_mut() + .set_string(div_area.x, y, "│", Style::default().fg(FG_DIM)); + } + render_body(frame, app, diff_area); + } else { + // Closed: the diff takes the full body width — the exact M4 look (locked design). + render_body(frame, app, body_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`) and needs-restack glyph +/// (amber ⚠, [`FG_WARN`] — locked decision #9's outline half); [`OutlineItem::File`]s carry an +/// indent, a one-character staged-ness glyph (blank for a committed changeset's files — see +/// [`crate::outline::StagedStatus`]'s doc comment for why no special-casing is needed here), and +/// the path. The cursor row (the outline's OWN cursor — a separate coordinate space from the +/// diff's [`App::cursor`]) gets [`BG_CURSOR`] while the outline has focus, or the dimmer +/// [`BG_OUTLINE_CURSOR_UNFOCUSED`] while it's merely open (so the remembered position stays +/// legible even after focus returns to the diff). +fn render_outline(frame: &mut Frame, app: &App, area: Rect) { + let items = app.outline_items(); + let cursor = app.outline_cursor(); + let focused = app.outline_focused(); + + let visible_h = area.height as usize; + let scroll = if visible_h == 0 { + 0 + } else if cursor >= visible_h { + cursor + 1 - visible_h + } else { + 0 + }; + + let buf = frame.buffer_mut(); + for row in 0..area.height { + let item_idx = scroll + row as usize; + let y = area.y + row; + let Some(item) = items.get(item_idx) else { + continue; + }; + let is_cursor = item_idx == cursor; + let line = build_outline_line(item); + let line = if is_cursor && focused { + apply_cursor_row(line, area.width) + } else if is_cursor { + apply_row_tint(line, area.width, BG_OUTLINE_CURSOR_UNFOCUSED) + } else { + line + }; + buf.set_line(area.x, y, &line, area.width); + } +} + +/// Build one outline row's rendered [`Line`] — see [`render_outline`]'s doc comment for the +/// marker rules. +fn build_outline_line(item: &OutlineItem) -> Line<'static> { + match item { + OutlineItem::Header { + label, + current, + needs_restack, + .. + } => { + let marker = if *current { "\u{25CF} " } else { " " }; + let mut spans = vec![TSpan::styled( + marker.to_string(), + Style::default().fg(FG_CURRENT), + )]; + spans.push(TSpan::styled( + label.clone(), + Style::default().add_modifier(Modifier::BOLD), + )); + if *needs_restack { + spans.push(TSpan::styled(" \u{26A0}", Style::default().fg(FG_WARN))); + } + Line::from(spans) + } + OutlineItem::File { path, status, .. } => { + let glyph = status.glyph(); + let text = format!(" {glyph} {path}"); + Line::from(TSpan::styled(text, Style::default().fg(FG_DEFAULT))) + } + } } /// The current file's label for the top status row: its path, or a rename's `old @ base -> @@ -442,9 +551,14 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ); } None => { - // A committed changeset is locked to the combined view (locked decision #2) — `z` - // zoom and `w` split-focus have nothing to act on, so drop them from the hint. - let text = if app.is_committed() { + // While the outline has focus, only outline-relevant keys act (locked design) — the + // diff-editing hint would be actively misleading, so show the outline's own hint + // instead. + let text = if app.outline_focused() { + "j/k move Enter jump i mode o unfocus Esc unfocus q quit" + } else if app.is_committed() { + // A committed changeset is locked to the combined view (locked decision #2) — `z` + // zoom and `w` split-focus have nothing to act on, so drop them from the hint. "j/k scroll v select s/S stage d/D discard q quit" } else { "j/k scroll v select s/S stage d/D discard z zoom w focus q quit" @@ -1856,4 +1970,170 @@ mod tests { render the dim 'already staged' pair, got {add_bg:?}" ); } + + // ── M5 CS3: outline side pane ─────────────────────────────────────────────── + + /// Every outline test renders at this width so the pane's fixed 35-col + 1-col-divider + /// layout is unambiguous: columns `0..35` are the outline, `35` the divider, `36..` the + /// diff. + const OUTLINE_TEST_WIDTH: u16 = 80; + + fn outline_row(buf: &Buffer, y: u16) -> String { + (0..35).map(|x| cell_text(buf, x, y)).collect() + } + + #[test] + fn outline_pane_renders_headers_when_open_and_disappears_when_closed() { + 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 must default-open the outline" + ); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + assert!( + content.iter().any(|row| row.contains("Add a")), + "expected the outline's Stack-mode header row for cs-a (rendered by its title), \ + got:\n{}", + content.join("\n") + ); + + // Default state is open+unfocused, so a single `o` closes it (see + // `App::toggle_outline`'s cycle). + app.toggle_outline(); + assert!(!app.outline_open()); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + assert!( + !content.iter().any(|row| row.contains("Add a")), + "closing the outline must stop rendering its rows, got:\n{}", + content.join("\n") + ); + } + + #[test] + fn outline_absent_for_a_lone_changeset() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + assert!(!app.outline_open()); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // The diff's own content (the M4 full-width look) must reach all the way to the left + // edge — column 0 — rather than starting past a 36-column outline+divider offset. + let row1: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 1)).collect(); + assert!( + row1.contains("a.txt") || row1.trim().is_empty() || row1.contains("one"), + "sanity: body row must be diff content, not outline chrome, got: {row1:?}" + ); + assert_ne!( + cell_text(&buf, 35, 1), + "│", + "a closed outline must not draw its divider column" + ); + } + + #[test] + fn outline_header_current_marker_uses_the_current_color() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); // cs-b is `current` + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row = content + .iter() + .position(|r| r.contains('\u{25CF}')) + .expect("current marker present in the outline"); + let marker_x = content[row].find('\u{25CF}').unwrap() as u16; + assert_eq!( + buf.cell((marker_x, row as u16)).unwrap().style().fg, + Some(super::FG_CURRENT), + "expected the outline's current marker to carry FG_CURRENT" + ); + } + + #[test] + fn outline_header_restack_marker_carries_the_warning_color() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); // cs-b needs_restack: true + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row = content + .iter() + .position(|r| r.contains('\u{26A0}')) + .expect("restack marker present in the outline"); + let marker_x = content[row].find('\u{26A0}').unwrap() as u16; + assert_eq!( + buf.cell((marker_x, row as u16)).unwrap().style().fg, + Some(super::FG_WARN), + "expected the outline's restack glyph to carry FG_WARN" + ); + } + + #[test] + fn outline_cursor_row_carries_cursor_background_when_focused() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + // Default is open+unfocused; two toggles: close, then reopen (which focuses). + app.toggle_outline(); + app.toggle_outline(); + assert!(app.outline_open() && app.outline_focused()); + + let cursor_y = 1 + app.outline_cursor() as u16; + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + assert_eq!( + buf.cell((2, cursor_y)).unwrap().style().bg, + Some(super::BG_CURSOR), + "expected the outline's cursor row to carry BG_CURSOR while focused" + ); + } + + #[test] + fn outline_flat_mode_dedupes_paths_across_the_stack() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.outline_cycle_mode(); // Stack -> Flat + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Flat); + + let items = app.outline_items(); + let paths: Vec<&str> = items + .iter() + .map(|it| match it { + crate::outline::OutlineItem::File { path, .. } => path.as_str(), + crate::outline::OutlineItem::Header { .. } => { + panic!("Flat mode must not emit header rows") + } + }) + .collect(); + let mut sorted = paths.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!( + paths.len(), + sorted.len(), + "every path must appear exactly once in Flat mode, got: {paths:?}" + ); + } } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index ed3d74e..3c98e45 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -72,13 +72,32 @@ enum Action { DiscardHunk, DiscardFile, StartSelection, + ToggleOutline, + OutlineMoveBy(i64), + OutlineConfirm, + OutlineCycleMode, + OutlineUnfocus, None, } /// Map one key press to an [`Action`], given `pending` (a `]` or `[` seen on the previous call, -/// awaiting its `f`/`h` suffix) and the current pane height (for `Ctrl-d`/`Ctrl-u` half-page -/// deltas). Unrecognized suffixes drop the pending bracket rather than re-processing the key. -fn map_key(pending: &mut Option, key: KeyEvent, pane_height: usize) -> Action { +/// awaiting its `f`/`h` suffix), the current pane height (for `Ctrl-d`/`Ctrl-u` half-page +/// deltas), and whether the outline pane currently has focus. Unrecognized suffixes drop the +/// pending bracket rather than re-processing the key. +/// +/// `outline_focused` re-routes the plain single-key map (NOT the bracket-pending path, which is +/// diff-only chording that can't be mid-flight while the outline has focus) to the outline's own +/// small key set (locked design: "only outline-relevant keys — `j k Enter i o Esc` — act" while +/// it has focus). `o` always toggles regardless of focus (checked before the split) since it's +/// the one key that must work from EITHER side to move focus between panes; `q` still quits from +/// either side too — the locked design only enumerates outline-focused keys, it doesn't say `q` +/// should stop working. +fn map_key( + pending: &mut Option, + key: KeyEvent, + pane_height: usize, + outline_focused: bool, +) -> Action { if let Some(bracket) = pending.take() { return match (bracket, key.code) { (']', KeyCode::Char('f')) => Action::NextFile, @@ -91,6 +110,22 @@ fn map_key(pending: &mut Option, key: KeyEvent, pane_height: usize) -> Act }; } + if key.code == KeyCode::Char('o') { + return Action::ToggleOutline; + } + + if outline_focused { + return match key.code { + KeyCode::Char('q') => Action::Quit, + KeyCode::Char('j') | KeyCode::Down => Action::OutlineMoveBy(1), + KeyCode::Char('k') | KeyCode::Up => Action::OutlineMoveBy(-1), + KeyCode::Enter => Action::OutlineConfirm, + KeyCode::Char('i') => Action::OutlineCycleMode, + KeyCode::Esc => Action::OutlineUnfocus, + _ => Action::None, + }; + } + match key.code { KeyCode::Char('q') | KeyCode::Esc => Action::Quit, KeyCode::Char('j') | KeyCode::Down => Action::MoveCursorBy(1), @@ -148,6 +183,11 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::DiscardHunk => app.discard_hunk(), Action::DiscardFile => app.discard_file(), Action::StartSelection => app.start_selection(), + Action::ToggleOutline => app.toggle_outline(), + Action::OutlineMoveBy(delta) => app.outline_move_by(delta), + Action::OutlineConfirm => app.outline_confirm(), + Action::OutlineCycleMode => app.outline_cycle_mode(), + Action::OutlineUnfocus => app.outline_unfocus(), Action::None => {} } false @@ -162,19 +202,23 @@ fn apply_action(app: &mut App, action: Action) -> bool { /// message and performs its normal action. `Resize`/`Tick` do NOT clear it: a redraw or timer /// tick isn't the user acting on the message. /// -/// Esc precedence (highest first): a pending discard confirm > an active line selection > the -/// normal key map (where Esc quits). Concretely: +/// Esc precedence (highest first): a pending discard confirm > the outline having focus > an +/// active line selection > the normal key map (where Esc quits). Concretely: /// /// 1. A pending discard confirm captures the keyboard FIRST (before the notice clear and the /// normal key map): `y` accepts, `n`/`Esc` cancels, and every other key is swallowed — a modal /// that neither clears the notice nor runs a normal action while it's up. -/// 2. Otherwise, with an active line selection, Esc CANCELS the selection instead of quitting (`q` +/// 2. Otherwise, while the outline pane has focus, Esc returns focus to the diff (via the normal +/// map's `outline_focused` branch — see [`map_key`]) rather than quitting or falling into the +/// selection-cancel case below (locked design: "Esc must still not quit when the outline has +/// focus"). The selection-Esc arm below is guarded to defer to this case. +/// 3. Otherwise, with an active line selection, Esc CANCELS the selection instead of quitting (`q` /// still quits). Other keys fall through to the normal map — `j`/`k` extend the selection, /// `s`/`d` act on it. -/// 3. Otherwise the normal map applies, where Esc (like `q`) quits. +/// 4. Otherwise the normal map applies, where Esc (like `q`) quits. /// -/// A `Key` event clears any showing footer notice before applying its own action (cases 2 and 3); -/// the confirm modal (case 1) deliberately does not. +/// A `Key` event clears any showing footer notice before applying its own action (cases 2-4); the +/// confirm modal (case 1) deliberately does not. fn update(app: &mut App, pending: &mut Option, event: AppEvent) -> bool { match event { AppEvent::Key(key) if app.pending_confirm.is_some() => { @@ -187,14 +231,21 @@ fn update(app: &mut App, pending: &mut Option, event: AppEvent) -> bool { } false } - AppEvent::Key(key) if app.selection_anchor.is_some() && key.code == KeyCode::Esc => { + AppEvent::Key(key) + if app.selection_anchor.is_some() + && key.code == KeyCode::Esc + && !app.outline_focused() => + { app.clear_notice(); app.cancel_selection(); false } AppEvent::Key(key) => { app.clear_notice(); - apply_action(app, map_key(pending, key, app.pane_height)) + apply_action( + app, + map_key(pending, key, app.pane_height, app.outline_focused()), + ) } AppEvent::Tick => { app.on_tick(); @@ -269,29 +320,32 @@ mod tests { fn quit_keys_map_to_quit() { let mut pending = None; assert_eq!( - map_key(&mut pending, key(KeyCode::Char('q')), 20), + map_key(&mut pending, key(KeyCode::Char('q')), 20, false), + Action::Quit + ); + assert_eq!( + map_key(&mut pending, key(KeyCode::Esc), 20, false), Action::Quit ); - assert_eq!(map_key(&mut pending, key(KeyCode::Esc), 20), Action::Quit); } #[test] fn scroll_keys_map_by_one_line() { let mut pending = None; assert_eq!( - map_key(&mut pending, key(KeyCode::Char('j')), 20), + map_key(&mut pending, key(KeyCode::Char('j')), 20, false), Action::MoveCursorBy(1) ); assert_eq!( - map_key(&mut pending, key(KeyCode::Down), 20), + map_key(&mut pending, key(KeyCode::Down), 20, false), Action::MoveCursorBy(1) ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('k')), 20), + map_key(&mut pending, key(KeyCode::Char('k')), 20, false), Action::MoveCursorBy(-1) ); assert_eq!( - map_key(&mut pending, key(KeyCode::Up), 20), + map_key(&mut pending, key(KeyCode::Up), 20, false), Action::MoveCursorBy(-1) ); } @@ -300,16 +354,16 @@ mod tests { fn ctrl_d_u_scroll_by_half_the_pane_height() { let mut pending = None; assert_eq!( - map_key(&mut pending, ctrl_key('d'), 21), + map_key(&mut pending, ctrl_key('d'), 21, false), Action::MoveCursorBy(10) ); assert_eq!( - map_key(&mut pending, ctrl_key('u'), 21), + map_key(&mut pending, ctrl_key('u'), 21, false), Action::MoveCursorBy(-10) ); // A pane height of 1 still scrolls by at least one line. assert_eq!( - map_key(&mut pending, ctrl_key('d'), 1), + map_key(&mut pending, ctrl_key('d'), 1, false), Action::MoveCursorBy(1) ); } @@ -318,11 +372,11 @@ mod tests { fn g_and_shift_g_map_to_top_and_bottom() { let mut pending = None; assert_eq!( - map_key(&mut pending, key(KeyCode::Char('g')), 20), + map_key(&mut pending, key(KeyCode::Char('g')), 20, false), Action::ScrollTop ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('G')), 20), + map_key(&mut pending, key(KeyCode::Char('G')), 20, false), Action::ScrollBottom ); } @@ -331,7 +385,7 @@ mod tests { fn shift_l_maps_to_toggle_layout() { let mut pending = None; assert_eq!( - map_key(&mut pending, key(KeyCode::Char('L')), 20), + map_key(&mut pending, key(KeyCode::Char('L')), 20, false), Action::ToggleLayout ); } @@ -340,11 +394,11 @@ mod tests { fn z_and_w_map_to_zoom_and_split_focus() { let mut pending = None; assert_eq!( - map_key(&mut pending, key(KeyCode::Char('z')), 20), + map_key(&mut pending, key(KeyCode::Char('z')), 20, false), Action::CycleZoom ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('w')), 20), + map_key(&mut pending, key(KeyCode::Char('w')), 20, false), Action::ToggleSplitFocus ); } @@ -353,7 +407,7 @@ mod tests { fn r_maps_to_refresh() { let mut pending = None; assert_eq!( - map_key(&mut pending, key(KeyCode::Char('r')), 20), + map_key(&mut pending, key(KeyCode::Char('r')), 20, false), Action::Refresh ); } @@ -362,11 +416,11 @@ mod tests { fn tab_and_backtab_map_to_file_nav() { let mut pending = None; assert_eq!( - map_key(&mut pending, key(KeyCode::Tab), 20), + map_key(&mut pending, key(KeyCode::Tab), 20, false), Action::NextFile ); assert_eq!( - map_key(&mut pending, key(KeyCode::BackTab), 20), + map_key(&mut pending, key(KeyCode::BackTab), 20, false), Action::PrevFile ); } @@ -375,22 +429,22 @@ mod tests { fn bracket_f_maps_to_file_nav() { let mut pending = None; assert_eq!( - map_key(&mut pending, key(KeyCode::Char(']')), 20), + map_key(&mut pending, key(KeyCode::Char(']')), 20, false), Action::None ); assert_eq!(pending, Some(']')); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('f')), 20), + map_key(&mut pending, key(KeyCode::Char('f')), 20, false), Action::NextFile ); assert_eq!(pending, None); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('[')), 20), + map_key(&mut pending, key(KeyCode::Char('[')), 20, false), Action::None ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('f')), 20), + map_key(&mut pending, key(KeyCode::Char('f')), 20, false), Action::PrevFile ); } @@ -398,15 +452,15 @@ mod tests { #[test] fn bracket_h_maps_to_hunk_nav() { let mut pending = None; - map_key(&mut pending, key(KeyCode::Char(']')), 20); + map_key(&mut pending, key(KeyCode::Char(']')), 20, false); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('h')), 20), + map_key(&mut pending, key(KeyCode::Char('h')), 20, false), Action::NextHunk ); - map_key(&mut pending, key(KeyCode::Char('[')), 20); + map_key(&mut pending, key(KeyCode::Char('[')), 20, false); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('h')), 20), + map_key(&mut pending, key(KeyCode::Char('h')), 20, false), Action::PrevHunk ); } @@ -414,9 +468,9 @@ mod tests { #[test] fn unrecognized_bracket_suffix_drops_pending_without_side_effect() { let mut pending = None; - map_key(&mut pending, key(KeyCode::Char(']')), 20); + map_key(&mut pending, key(KeyCode::Char(']')), 20, false); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('x')), 20), + map_key(&mut pending, key(KeyCode::Char('x')), 20, false), Action::None ); assert_eq!( @@ -545,24 +599,24 @@ mod tests { fn staging_keys_map_to_their_actions() { let mut pending = None; assert_eq!( - map_key(&mut pending, key(KeyCode::Char('s')), 20), + map_key(&mut pending, key(KeyCode::Char('s')), 20, false), Action::StageHunk ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('S')), 20), + map_key(&mut pending, key(KeyCode::Char('S')), 20, false), Action::StageFile ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('d')), 20), + map_key(&mut pending, key(KeyCode::Char('d')), 20, false), Action::DiscardHunk ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('D')), 20), + map_key(&mut pending, key(KeyCode::Char('D')), 20, false), Action::DiscardFile ); // Ctrl-d keeps its half-page meaning — the plain-`d` staging arm must not shadow it. assert_eq!( - map_key(&mut pending, ctrl_key('d'), 20), + map_key(&mut pending, ctrl_key('d'), 20, false), Action::MoveCursorBy(10) ); } @@ -571,7 +625,7 @@ mod tests { fn v_maps_to_start_selection() { let mut pending = None; assert_eq!( - map_key(&mut pending, key(KeyCode::Char('v')), 20), + map_key(&mut pending, key(KeyCode::Char('v')), 20, false), Action::StartSelection ); } @@ -668,4 +722,196 @@ mod tests { let repo = fixture.repo().unwrap(); repo.assert(predicate::repo::workdir_file_equals("a.txt", "one\ntwo\n")); } + + // ── M5 CS3: outline pane key routing ───────────────────────────────────── + + /// A two-committed-changeset stack, built the same way as `app.rs`/`render.rs`'s own M5 + /// tests — `tui.rs` needs its own copy since it compiles into the separate bin crate (see + /// `app_from_fixture`'s doc comment above for why the helpers can't be shared directly). + fn two_committed_changesets_app(fixture: &git_workon_fixture::fixture::Fixture) -> App { + use git2::Repository; + use workon::{Changeset, ChangesetSource}; + use workon_review::acquire::diff_changeset; + use workon_review::app::ChangesetView; + + 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(), + source: ChangesetSource::Committed { + base: root, + head: mid, + }, + title: None, + current: false, + needs_restack: false, + }; + let cs_b = Changeset { + name: "cs-b".to_string(), + source: ChangesetSource::Committed { base: mid, head }, + title: None, + current: true, + needs_restack: false, + }; + let view_a = + ChangesetView::from_changeset_diff(cs_a.clone(), diff_changeset(repo, &cs_a).unwrap()); + let view_b = + ChangesetView::from_changeset_diff(cs_b.clone(), 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(); + app + } + + #[test] + fn o_key_toggles_the_outline_through_its_full_cycle() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + let mut pending = None; + // Default: open, unfocused. + assert!(app.outline_open() && !app.outline_focused()); + + update( + &mut app, + &mut pending, + AppEvent::Key(key(KeyCode::Char('o'))), + ); + assert!(!app.outline_open(), "o from open+unfocused closes the pane"); + + update( + &mut app, + &mut pending, + AppEvent::Key(key(KeyCode::Char('o'))), + ); + assert!( + app.outline_open() && app.outline_focused(), + "o from closed opens AND focuses the pane" + ); + + update( + &mut app, + &mut pending, + AppEvent::Key(key(KeyCode::Char('o'))), + ); + assert!( + app.outline_open() && !app.outline_focused(), + "o from open+focused returns focus to the diff without closing" + ); + } + + #[test] + fn outline_focused_j_k_move_the_outline_cursor_not_the_diff() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.toggle_outline(); // close + app.toggle_outline(); // open + focus, cursor synced onto cs-b's file row (the LAST row) + assert!(app.outline_focused()); + let diff_cursor_before = app.cursor; + let diff_file_before = app.current; + let outline_cursor_before = app.outline_cursor(); + let mut pending = None; + + // `k` (not `j`): the outline cursor starts on the last row (cs-b's file, since it's the + // active/current changeset), so `j` would clamp in place — `k` has room to move. + update( + &mut app, + &mut pending, + AppEvent::Key(key(KeyCode::Char('k'))), + ); + + assert_ne!( + app.outline_cursor(), + outline_cursor_before, + "k while the outline has focus must move the OUTLINE cursor" + ); + assert_eq!( + (app.cursor, app.current), + (diff_cursor_before, diff_file_before), + "j while the outline has focus must not move the diff's own cursor \ + (unless the outline cursor happened to land on a different file row, which the \ + two-changeset fixture's first outline move does not)" + ); + } + + #[test] + fn esc_does_not_quit_while_the_outline_has_focus() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.toggle_outline(); // close + app.toggle_outline(); // open + focus + assert!(app.outline_focused()); + let mut pending = None; + + let quit = update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Esc))); + + assert!(!quit, "Esc must not quit while the outline has focus"); + assert!( + !app.outline_focused(), + "Esc while the outline has focus returns focus to the diff" + ); + assert!( + app.outline_open(), + "Esc must not also close the pane, only unfocus it" + ); + } + + #[test] + fn enter_confirms_an_outline_jump_and_returns_focus_to_the_diff() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.toggle_outline(); // close + app.toggle_outline(); // open + focus, cursor synced onto cs-b's file row + assert!(app.outline_focused()); + // Move the outline cursor up onto cs-a's header row. + app.outline_move_by(-3); + let mut pending = None; + + update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Enter))); + + assert_eq!( + app.current_cs(), + 0, + "Enter on cs-a's header must jump there" + ); + assert_eq!(app.current, 0, "...landing on its first file"); + assert!( + !app.outline_focused(), + "Enter returns focus to the diff after jumping" + ); + } } From 6e00631bbf6310382f6d9f4dce1da0d820d37dc9 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 12:17:02 -0400 Subject: [PATCH 040/203] feat(review): add outline tree and stack-tree modes --- git-workon-review/src/app.rs | 136 +++++++++++- git-workon-review/src/outline.rs | 343 ++++++++++++++++++++++++++++++- git-workon-review/src/render.rs | 133 +++++++++++- 3 files changed, 591 insertions(+), 21 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 350dc17..0c5f851 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1494,7 +1494,10 @@ impl App { // so the cursor follows off the header row onto the file it just jumped to. self.sync_outline_to_current(); } - None => {} + // A directory row (Tree/StackTree modes) is not a jump target — no expand/collapse + // state exists to toggle (CS4 decision), so Enter here is a no-op beyond the + // unconditional unfocus below, same as confirming on nothing at all. + Some(OutlineItem::Dir { .. }) | None => {} } self.outline.focused = false; } @@ -4751,18 +4754,25 @@ mod tests { } #[test] - fn outline_cycle_mode_switches_between_flat_and_stack() { + fn outline_cycle_mode_round_trips_all_four_modes() { let mut app = two_committed_changesets_two_and_one_files(); let start = app.outline_mode(); - app.outline_cycle_mode(); - assert_ne!(app.outline_mode(), start); + let mut seen = vec![start]; + for _ in 0..3 { + app.outline_cycle_mode(); + assert!( + !seen.contains(&app.outline_mode()), + "each of the first 4 cycles must be a mode not yet seen" + ); + seen.push(app.outline_mode()); + } app.outline_cycle_mode(); assert_eq!( app.outline_mode(), start, - "cycling twice returns to the start" + "the 4th cycle returns to the start" ); } @@ -4860,6 +4870,7 @@ mod tests { file_idx: 0, path: "c1.txt".to_string(), status: StagedStatus::None, + guides: Vec::new(), }, "a committed changeset's file must carry no staged-ness status" ); @@ -4952,11 +4963,126 @@ mod tests { file_idx: 0, path: "b1.txt".to_string(), status: StagedStatus::None, + guides: Vec::new(), }, "the outline cursor must follow the diff's new position" ); } + #[test] + fn diff_initiated_nav_syncs_the_outline_cursor_in_tree_mode() { + // CS4: Tree mode's rows still carry the same cs_idx/file_idx a File row always has, so + // `sync_outline_to_current`'s match-by-those-fields logic needs no tree-specific branch — + // this pins that it actually still lands correctly once the row also carries `guides`. + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Tree; + + app.next_changeset(); + assert!( + !app.outline_focused(), + "a diff-initiated nav must never steal focus from the diff to the outline" + ); + let items = app.outline_items(); + assert_eq!( + items[app.outline_cursor()], + OutlineItem::File { + cs_idx: 1, + file_idx: 0, + path: "b1.txt".to_string(), + status: StagedStatus::None, + guides: vec![true], + }, + "the outline cursor must follow the diff's new position, landing on b1.txt's row \ + even though Tree mode reshuffles the row order alphabetically" + ); + } + + #[test] + fn outline_cycle_mode_reaches_tree_and_stack_tree() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + + app.outline_cycle_mode(); + assert_eq!(app.outline_mode(), OutlineMode::Tree); + + app.outline_cycle_mode(); + assert_eq!(app.outline_mode(), OutlineMode::StackTree); + } + + /// A single committed changeset touching two files under `src/`, for the Dir-row no-op + /// tests — the two-and-one-files fixture above is deliberately flat and never produces a + /// [`OutlineItem::Dir`] row in Tree mode. + fn single_changeset_with_nested_paths() -> App { + 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("src/a.txt", "a\n") + .file("src/b.txt", "b\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + + let cs = Changeset { + name: "cs".to_string(), + source: ChangesetSource::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(); + app + } + + #[test] + fn outline_move_by_and_confirm_on_a_dir_row_do_not_jump_the_diff() { + let mut app = single_changeset_with_nested_paths(); + app.outline.mode = OutlineMode::Tree; + let items = app.outline_items(); + let dir_idx = items + .iter() + .position(|it| matches!(it, OutlineItem::Dir { name, .. } if name == "src")) + .expect("src/ dir row present in Tree mode"); + + let before_cs = app.current_cs(); + let before_file = app.current; + + app.outline.cursor = dir_idx; + app.outline_move_by(0); + assert_eq!(app.current_cs(), before_cs); + assert_eq!( + app.current, before_file, + "moving onto a Dir row must not jump the diff" + ); + + app.outline.cursor = dir_idx; + app.outline.focused = true; + app.outline_confirm(); + assert_eq!(app.current_cs(), before_cs); + assert_eq!( + app.current, before_file, + "confirming a Dir row must not jump the diff" + ); + assert!( + !app.outline_focused(), + "confirm still returns focus to the diff, even as a no-op" + ); + } + #[test] fn closing_the_outline_restores_full_width_diff_rendering() { // A render-level assertion belongs in render.rs's own tests; this just pins the state diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 1a09c03..27dd12d 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -3,8 +3,11 @@ //! renders and the outline cursor indexes — no [`crate::app::App`]/[`crate::app::ChangesetView`] //! dependency, mirroring how [`crate::attribute`] stays a pure module consumed by `app`/`render`. //! -//! CS3 ships two of the eventual four modes ([`OutlineMode::Flat`]/[`OutlineMode::Stack`]); the -//! two path-trie modes (tree / stack-tree) are CS4's addition to this same module. +//! CS3 shipped two of the four modes ([`OutlineMode::Flat`]/[`OutlineMode::Stack`]); CS4 (this +//! revision) adds the two path-trie modes ([`OutlineMode::Tree`]/[`OutlineMode::StackTree`]) via +//! the private [`TrieNode`] builder below. + +use std::collections::HashMap; /// Which of the outline's row-building strategies is active — cycled by `i` (only while the /// outline pane has focus; see `App::outline_cycle_mode`). @@ -17,14 +20,25 @@ pub enum OutlineMode { /// structure M5 exists to surface). #[default] Stack, + /// [`Self::Flat`]'s de-duped path set, rendered as a directory trie (dir rows + file leaves) + /// instead of a bare list. + Tree, + /// [`Self::Stack`]'s per-changeset grouping, but each changeset's files are rendered as their + /// own nested trie instead of a bare list. + StackTree, } impl OutlineMode { - /// `i`'s cycle order: `Flat -> Stack -> Flat`. CS4 will extend this to all four modes. + /// `i`'s cycle order: `Flat -> Stack -> Tree -> StackTree -> Flat`. Flat/Stack (the + /// non-trie modes) come first since they're the CS3 default pair; the trie modes follow in + /// the same flat/grouped pairing (Tree mirrors Flat's cross-stack dedup, StackTree mirrors + /// Stack's per-changeset grouping). pub fn cycle(self) -> Self { match self { OutlineMode::Flat => OutlineMode::Stack, - OutlineMode::Stack => OutlineMode::Flat, + OutlineMode::Stack => OutlineMode::Tree, + OutlineMode::Tree => OutlineMode::StackTree, + OutlineMode::StackTree => OutlineMode::Flat, } } } @@ -101,30 +115,64 @@ pub struct OutlineChangeset { /// index into `App`'s changeset list the row belongs to; `file_idx` (on [`Self::File`]) is the /// index into THAT changeset's file list — together they're exactly what /// `App::switch_changeset`/`App::goto_changeset` need to jump the diff there. +/// +/// `guides` (on [`Self::Dir`]/[`Self::File`]) is the tree-guide vector CS4 adds: one bool per +/// nesting level from the shallowest ancestor down to the row itself, `true` meaning "this +/// level is its parent's last child". Rendering uses every-element-but-the-last to decide +/// whether to draw a continuing `│` or blank space at that column, and the last element to draw +/// `└─`/`├─` for the row's own connector. [`OutlineMode::Flat`]/[`OutlineMode::Stack`] rows carry +/// an EMPTY `guides` — that's the signal to `render::build_outline_line` to fall back to the +/// flat two-space indent instead of drawing tree connectors; a non-empty `guides` of length 1 +/// means "top-level tree row" (depth 0), so emptiness and depth-0 are deliberately distinguishable. #[derive(Debug, Clone, PartialEq, Eq)] pub enum OutlineItem { - /// A changeset header — only emitted in [`OutlineMode::Stack`]. + /// A changeset header — emitted in [`OutlineMode::Stack`]/[`OutlineMode::StackTree`]. Header { cs_idx: usize, label: String, current: bool, needs_restack: bool, }, - /// A file row — the target of every outline->diff jump. + /// A directory row — only emitted in [`OutlineMode::Tree`]/[`OutlineMode::StackTree`]. Not a + /// jump target: it carries no `cs_idx`/`file_idx`, so `App::outline_move_by` no-ops on it + /// (same as [`Self::Header`]) and `App::outline_confirm` also no-ops on it (CS4 decision — + /// there's no expand/collapse state to toggle, so Enter on a directory row does nothing but + /// still returns focus to the diff, matching every other confirm outcome). + Dir { name: String, guides: Vec }, + /// A file row — the target of every outline->diff jump. `path` is the FULL path in + /// [`OutlineMode::Flat`]/[`OutlineMode::Stack`] (unchanged from CS3), but is just the leaf + /// segment in [`OutlineMode::Tree`]/[`OutlineMode::StackTree`] — the ancestor directory rows + /// already carry the rest of the path, so re-printing it on every leaf would be redundant. File { cs_idx: usize, file_idx: usize, path: String, status: StagedStatus, + guides: Vec, }, } +impl OutlineItem { + /// Nesting depth for indentation math: `0` for [`Self::Header`] and any flat/stack row + /// (empty `guides`), else `guides.len() - 1`. + pub fn depth(&self) -> usize { + match self { + OutlineItem::Header { .. } => 0, + OutlineItem::Dir { guides, .. } | OutlineItem::File { guides, .. } => { + guides.len().saturating_sub(1) + } + } + } +} + /// Build the outline's row list for `mode` from every reviewed changeset, in the same base -> /// head order `App::changesets` holds them. pub fn build_items(changesets: &[OutlineChangeset], mode: OutlineMode) -> Vec { match mode { OutlineMode::Flat => build_flat(changesets), OutlineMode::Stack => build_stack(changesets), + OutlineMode::Tree => build_tree(changesets), + OutlineMode::StackTree => build_stack_tree(changesets), } } @@ -146,6 +194,7 @@ fn build_stack(changesets: &[OutlineChangeset]) -> Vec { file_idx, path: file.path.clone(), status: file.status, + guides: Vec::new(), }); } } @@ -160,8 +209,7 @@ fn build_stack(changesets: &[OutlineChangeset]) -> Vec { /// committed one. fn build_flat(changesets: &[OutlineChangeset]) -> Vec { let mut order: Vec = Vec::new(); - let mut latest: std::collections::HashMap = - std::collections::HashMap::new(); + let mut latest: HashMap = HashMap::new(); for (cs_idx, cs) in changesets.iter().enumerate() { for (file_idx, file) in cs.files.iter().enumerate() { if !latest.contains_key(&file.path) { @@ -179,11 +227,143 @@ fn build_flat(changesets: &[OutlineChangeset]) -> Vec { file_idx, path, status, + guides: Vec::new(), } }) .collect() } +/// De-dupe every changed path across the stack to its LAST occurrence (mirrors +/// [`build_flat`]'s last-write-wins rule), independent of iteration/insertion order — the trie +/// builders below re-sort by path segment anyway, so no stable-order bookkeeping is needed here. +fn latest_by_path( + changesets: &[OutlineChangeset], +) -> HashMap { + let mut latest = HashMap::new(); + for (cs_idx, cs) in changesets.iter().enumerate() { + for (file_idx, file) in cs.files.iter().enumerate() { + latest.insert(file.path.clone(), (cs_idx, file_idx, file.status)); + } + } + latest +} + +/// A node in the path trie the tree modes build. A node with `file.is_some()` is a leaf (a +/// changed file at that exact path); otherwise it's a pure directory node. Git paths never +/// collide a file and a directory at the same path, so a node is never both. +#[derive(Debug, Default)] +struct TrieNode { + file: Option<(usize, usize, StagedStatus)>, + /// Insertion order is irrelevant — [`emit`] re-sorts children (dirs-after-files, alpha + /// within group) every time it flattens a node. + children: Vec<(String, TrieNode)>, +} + +impl TrieNode { + fn insert(&mut self, segments: &[&str], cs_idx: usize, file_idx: usize, status: StagedStatus) { + let (head, rest) = segments + .split_first() + .expect("insert is never called with an empty segment list"); + let idx = match self.children.iter().position(|(name, _)| name == head) { + Some(idx) => idx, + None => { + self.children.push((head.to_string(), TrieNode::default())); + self.children.len() - 1 + } + }; + let child = &mut self.children[idx].1; + if rest.is_empty() { + // Last-write-wins: a later insert of the same full path overwrites the leaf data. + child.file = Some((cs_idx, file_idx, status)); + } else { + child.insert(rest, cs_idx, file_idx, status); + } + } +} + +/// Flatten `node`'s children into `items`, depth-first, in "dirs after files at each level, +/// alpha within group" order (matches the `~/.config/nvim/lua/app/review/ui/outline.lua` +/// prototype's `_build_path_tree`/`_emit_tree_node`: files read before directories at a given +/// level, so a directory's own contents don't visually separate its sibling files from the +/// directory listing above them). `ancestors_last` is the growing guide vector — see +/// [`OutlineItem`]'s doc comment for how rendering consumes it. +fn emit(node: &TrieNode, ancestors_last: &[bool], items: &mut Vec) { + let mut files: Vec<&(String, TrieNode)> = node + .children + .iter() + .filter(|(_, n)| n.file.is_some()) + .collect(); + let mut dirs: Vec<&(String, TrieNode)> = node + .children + .iter() + .filter(|(_, n)| n.file.is_none()) + .collect(); + files.sort_by(|a, b| a.0.cmp(&b.0)); + dirs.sort_by(|a, b| a.0.cmp(&b.0)); + let ordered: Vec<&(String, TrieNode)> = files.into_iter().chain(dirs).collect(); + let n = ordered.len(); + for (i, (name, child)) in ordered.into_iter().enumerate() { + let is_last = i == n - 1; + let mut guides = ancestors_last.to_vec(); + guides.push(is_last); + match child.file { + Some((cs_idx, file_idx, status)) => { + items.push(OutlineItem::File { + cs_idx, + file_idx, + path: name.clone(), + status, + guides, + }); + } + None => { + items.push(OutlineItem::Dir { + name: name.clone(), + guides: guides.clone(), + }); + emit(child, &guides, items); + } + } + } +} + +/// [`OutlineMode::Tree`]: [`build_flat`]'s de-duped path set, rendered as a single directory +/// trie spanning the whole stack (no changeset headers). +fn build_tree(changesets: &[OutlineChangeset]) -> Vec { + let latest = latest_by_path(changesets); + let mut root = TrieNode::default(); + for (path, (cs_idx, file_idx, status)) in &latest { + let segments: Vec<&str> = path.split('/').collect(); + root.insert(&segments, *cs_idx, *file_idx, *status); + } + let mut items = Vec::new(); + emit(&root, &[], &mut items); + items +} + +/// [`OutlineMode::StackTree`]: [`build_stack`]'s per-changeset header grouping, but each +/// changeset's own files are flattened into their own nested trie (no cross-changeset dedup — +/// each changeset trie is built from just that changeset's files, matching `build_stack`'s "every +/// changeset's own copy gets its own row" rule). +fn build_stack_tree(changesets: &[OutlineChangeset]) -> Vec { + let mut items = Vec::new(); + for (cs_idx, cs) in changesets.iter().enumerate() { + items.push(OutlineItem::Header { + cs_idx, + label: cs.label.clone(), + current: cs.current, + needs_restack: cs.needs_restack, + }); + let mut root = TrieNode::default(); + for (file_idx, file) in cs.files.iter().enumerate() { + let segments: Vec<&str> = file.path.split('/').collect(); + root.insert(&segments, cs_idx, file_idx, file.status); + } + emit(&root, &[], &mut items); + } + items +} + #[cfg(test)] mod tests { use super::*; @@ -229,6 +409,7 @@ mod tests { file_idx: 0, path: "a1.txt".to_string(), status: StagedStatus::None, + guides: Vec::new(), }, OutlineItem::Header { cs_idx: 1, @@ -241,6 +422,7 @@ mod tests { file_idx: 0, path: "b1.txt".to_string(), status: StagedStatus::None, + guides: Vec::new(), }, ] ); @@ -284,6 +466,7 @@ mod tests { file_idx: 0, path: "shared.txt".to_string(), status: StagedStatus::Unstaged, + guides: Vec::new(), }, "must point at cs-b (the LATER/newer changeset), not cs-a" ); @@ -308,7 +491,7 @@ mod tests { .iter() .map(|it| match it { OutlineItem::File { path, .. } => path.as_str(), - OutlineItem::Header { .. } => unreachable!(), + OutlineItem::Header { .. } | OutlineItem::Dir { .. } => unreachable!(), }) .collect(); assert_eq!( @@ -330,8 +513,146 @@ mod tests { } #[test] - fn mode_cycles_between_flat_and_stack() { + fn mode_cycle_round_trips_all_four_modes() { assert_eq!(OutlineMode::Flat.cycle(), OutlineMode::Stack); - assert_eq!(OutlineMode::Stack.cycle(), OutlineMode::Flat); + assert_eq!(OutlineMode::Stack.cycle(), OutlineMode::Tree); + assert_eq!(OutlineMode::Tree.cycle(), OutlineMode::StackTree); + assert_eq!(OutlineMode::StackTree.cycle(), OutlineMode::Flat); + } + + /// Deep-path fixture used by the tree-mode tests: a top-level file, a top-level directory + /// with both its own file and a nested subdirectory of two more files — enough to exercise + /// depth > 1 and the dirs-after-files/alpha-within-group ordering at every level. + fn deep_path_changeset(label: &str, current: bool, needs_restack: bool) -> OutlineChangeset { + cs( + label, + current, + needs_restack, + &[ + ("top.rs", StagedStatus::None), + ("src/a/b.rs", StagedStatus::None), + ("src/a/c.rs", StagedStatus::None), + ("src/d.rs", StagedStatus::None), + ], + ) + } + + #[test] + fn tree_mode_builds_dirs_after_files_alpha_within_group_with_correct_depth_and_guides() { + let changesets = vec![deep_path_changeset("cs-a", true, false)]; + let items = build_items(&changesets, OutlineMode::Tree); + assert_eq!( + items, + vec![ + OutlineItem::File { + cs_idx: 0, + file_idx: 0, + path: "top.rs".to_string(), + status: StagedStatus::None, + guides: vec![false], + }, + OutlineItem::Dir { + name: "src".to_string(), + guides: vec![true], + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 3, + path: "d.rs".to_string(), + status: StagedStatus::None, + guides: vec![true, false], + }, + OutlineItem::Dir { + name: "a".to_string(), + guides: vec![true, true], + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 1, + path: "b.rs".to_string(), + status: StagedStatus::None, + guides: vec![true, true, false], + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 2, + path: "c.rs".to_string(), + status: StagedStatus::None, + guides: vec![true, true, true], + }, + ], + "root: top.rs (file) then src/ (dir); under src/: d.rs (file) then a/ (dir); \ + under src/a/: b.rs then c.rs — files-before-dirs, alpha within each group" + ); + assert_eq!(items[0].depth(), 0, "top.rs is a root-level row"); + assert_eq!(items[1].depth(), 0, "src/ is a root-level row"); + assert_eq!(items[2].depth(), 1, "src/d.rs is one level deep"); + assert_eq!(items[4].depth(), 2, "src/a/b.rs is two levels deep"); + } + + #[test] + fn tree_mode_dedupes_a_shared_path_to_the_newest_changesets_occurrence() { + let changesets = vec![ + cs("cs-a", false, false, &[("shared.txt", StagedStatus::None)]), + cs("cs-b", true, false, &[("shared.txt", StagedStatus::Staged)]), + ]; + let items = build_items(&changesets, OutlineMode::Tree); + assert_eq!( + items, + vec![OutlineItem::File { + cs_idx: 1, + file_idx: 0, + path: "shared.txt".to_string(), + status: StagedStatus::Staged, + guides: vec![true], + }], + "the shared path must appear exactly once, pointing at the newer changeset" + ); + } + + #[test] + fn stack_tree_mode_nests_each_changesets_files_under_its_own_header() { + let changesets = vec![ + cs("cs-a", false, false, &[("x/y.txt", StagedStatus::None)]), + cs("cs-b", true, true, &[("z.txt", StagedStatus::Unstaged)]), + ]; + let items = build_items(&changesets, OutlineMode::StackTree); + assert_eq!( + items, + vec![ + OutlineItem::Header { + cs_idx: 0, + label: "cs-a".to_string(), + current: false, + needs_restack: false, + }, + OutlineItem::Dir { + name: "x".to_string(), + guides: vec![true], + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 0, + path: "y.txt".to_string(), + status: StagedStatus::None, + guides: vec![true, true], + }, + OutlineItem::Header { + cs_idx: 1, + label: "cs-b".to_string(), + current: true, + needs_restack: true, + }, + OutlineItem::File { + cs_idx: 1, + file_idx: 0, + path: "z.txt".to_string(), + status: StagedStatus::Unstaged, + guides: vec![true], + }, + ], + "each changeset's files form their own trie nested under that changeset's header, \ + with no cross-changeset dedup" + ); } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 7cc1b9d..d0087d9 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -428,6 +428,26 @@ fn render_outline(frame: &mut Frame, app: &App, area: Rect) { } } +/// Render a tree-guide prefix from an [`OutlineItem::Dir`]/[`OutlineItem::File`] `guides` +/// vector: every element but the last draws a continuing `│` (if that ancestor level was NOT +/// its parent's last child) or blank space (if it was), and the last element draws the row's own +/// `└─`/`├─` connector. +fn tree_prefix(guides: &[bool]) -> String { + let mut s = String::new(); + let Some((&is_last, ancestors)) = guides.split_last() else { + return s; + }; + for &last in ancestors { + s.push_str(if last { " " } else { "\u{2502} " }); + } + s.push_str(if is_last { + "\u{2514}\u{2500} " + } else { + "\u{251C}\u{2500} " + }); + s +} + /// Build one outline row's rendered [`Line`] — see [`render_outline`]'s doc comment for the /// marker rules. fn build_outline_line(item: &OutlineItem) -> Line<'static> { @@ -452,9 +472,29 @@ fn build_outline_line(item: &OutlineItem) -> Line<'static> { } Line::from(spans) } - OutlineItem::File { path, status, .. } => { + OutlineItem::Dir { name, guides } => { + let text = format!("{}{name}/", tree_prefix(guides)); + Line::from(TSpan::styled( + text, + Style::default().fg(FG_DIM).add_modifier(Modifier::ITALIC), + )) + } + OutlineItem::File { + path, + status, + guides, + .. + } => { let glyph = status.glyph(); - let text = format!(" {glyph} {path}"); + // Empty `guides` (Flat/Stack modes) keeps the original two-space indent; a + // non-empty `guides` (Tree/StackTree modes) draws tree connectors instead — see + // `OutlineItem`'s doc comment for why emptiness is the mode signal. + let prefix = if guides.is_empty() { + " ".to_string() + } else { + tree_prefix(guides) + }; + let text = format!("{prefix}{glyph} {path}"); Line::from(TSpan::styled(text, Style::default().fg(FG_DEFAULT))) } } @@ -2114,7 +2154,9 @@ mod tests { .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); - app.outline_cycle_mode(); // Stack -> Flat + app.outline_cycle_mode(); // Stack -> Tree + app.outline_cycle_mode(); // Tree -> StackTree + app.outline_cycle_mode(); // StackTree -> Flat assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Flat); let items = app.outline_items(); @@ -2122,8 +2164,9 @@ mod tests { .iter() .map(|it| match it { crate::outline::OutlineItem::File { path, .. } => path.as_str(), - crate::outline::OutlineItem::Header { .. } => { - panic!("Flat mode must not emit header rows") + crate::outline::OutlineItem::Header { .. } + | crate::outline::OutlineItem::Dir { .. } => { + panic!("Flat mode must not emit header or dir rows") } }) .collect(); @@ -2136,4 +2179,84 @@ mod tests { "every path must appear exactly once in Flat mode, got: {paths:?}" ); } + + /// A single committed changeset touching a nested path (`src/a.txt`) and a top-level path + /// (`top.txt`), for the Tree-mode render test — the outline test fixtures above are + /// deliberately flat and never produce a directory row. + fn changeset_with_nested_paths(fixture: &Fixture) -> App { + use git2::Repository; + use workon::{Changeset, ChangesetSource}; + + use crate::app::ChangesetView; + + let root = fixture + .commit("main") + .file("root.txt", "r\n") + .create("root") + .unwrap(); + let head = fixture + .commit("main") + .file("top.txt", "t\n") + .file("src/a.txt", "a\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + + let cs = Changeset { + name: "cs".to_string(), + source: ChangesetSource::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(); + app + } + + #[test] + fn outline_tree_mode_renders_directory_rows_with_tree_guides() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = changeset_with_nested_paths(&fixture); + // A lone changeset defaults the outline closed (locked design) — force it open so this + // render test can inspect its rows. + if !app.outline_open() { + app.toggle_outline(); + } + app.outline_cycle_mode(); // Stack -> Tree + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + + // Row order per the dirs-after-files/alpha-within-group rule, one outline row per + // buffer row starting at y=1 (y=0 is the winbar): `top.txt` (file, root, NOT the root's + // last child — `src/` follows), `src/` (dir, root, IS the root's last child), then + // `a.txt` nested one level under `src/` (the only — hence last — child of `src/`). + assert!( + content[1].contains('\u{251C}') && content[1].contains("top.txt"), + "expected row 1 to be top.txt with a non-last '├─' guide, got:\n{}", + content.join("\n") + ); + assert!( + content[2].contains('\u{2514}') && content[2].contains("src/"), + "expected row 2 to be the src/ directory row with a last-child '└─' guide, got:\n{}", + content.join("\n") + ); + assert!( + content[3].contains('\u{2514}') && content[3].contains("a.txt"), + "expected row 3 to be src/a.txt, indented under src/ with its own last-child '└─' \ + guide, got:\n{}", + content.join("\n") + ); + } } From 86353cf202474e0481871f3f2655089aaf01c6b3 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 12:19:54 -0400 Subject: [PATCH 041/203] docs(rfc): mark M5 stack sources and outline done --- docs/rfc/workon-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index f1c9aed..f164038 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -131,7 +131,7 @@ evidence, not to the conclusion. - **M2 — trap corpus port.** Diff parser + patch synthesis in the review lib, the six trap items as tests, git2-vs-CLI verdict rendered (and the write-path decision recorded here). Acceptance: round-trip corpus green against real repos. — DONE (2026-07-06): corpus green on both backends; verdict recorded above. - **M3 — renderer + uncommitted source.** Port spike modules; wire changeset → parsed diff → SBS/inline render; file nav; the uncommitted source end-to-end. Acceptance: dogfood-able read-only review of a dirty worktree. — DONE (2026-07-06): combined-zoom read-only review with SBS + inline layouts, collapsed context gaps, word-diff emphasis, tree-sitter highlighting (spike's 8 grammars; syntect deferred), file/hunk nav; dogfooded against a dirty worktree. Port note: the spike's `compose_segments` had a latent first-match span-precedence bug that silently dropped word-level emphasis — fixed here (reverse-order lookup), pinned by a three-way bg test in `render.rs`. - **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. Design locked 2026-07-06 (plan artifact `iron-lattice`): (1) staging = prototype parity — verbs act only in unstaged/staged panes, combined refuses, direction = pane role (combined-native toggle deferred); (2) cursor-primary nav in all views, scroll derived; (3) full 4-state zoom (`split→combined→unstaged→staged`) with per-file `_gate` downgrade and stacked split panes (per-pane cursor, `w` focus), no collapse debounce; (4) runtime stays sync — poll `IndexSignature` on Tick, synchronous re-diff (no threads/notify dep); (5) queue enqueue+drain same beat, refresh, re-snapshot; (6) footer-swap for refusals/errors + discard confirm; (7) attribution via a new pure `attribute.rs` (membership sets keyed by lnum); (8) line selection in both layouts (inline one-sided, SBS row-pair). — DONE (2026-07-07): shipped as EIGHT changesets `m4-cursor → m4-zoom → m4-attribute → m4-notify → m4-refresh → m4-stage → m4-select → m4-watch` (staging split into hunk/file vs line selection; refresh pulled out as shared infra for stage + watch). Stack-reviewed continuously on the main thread; two real bugs caught by review, not by agent tests: (a) m4-zoom sub-view panes rendered worktree text where index text belonged — fixed with per-role blob sourcing (`read_index_blob`); (b) m4-select applied a multi-hunk line selection as N independent patches, which libgit2 rejects because each per-hunk patch's line numbering assumes the others are present — fixed by merging into ONE `PatchText` (`ops::apply_line_selections`), pinned by a line-shift tripwire test. Acceptance met: staging parity dogfooded against real git (stage/unstage/discard hunk/file/line, partial-hunk selection); index watcher confirmed live (external `git add` auto-refreshes on the next Tick — the watcher polls `.git/index`'s signature, so it catches index writes, not bare worktree edits, matching its name). Runtime stayed sync (no threads); combined-native staging toggle and spike `--dump`/`--bench` modes remain deferred. -- **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). +- **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). Design locked 2026-07-07 (plan artifact `cairn-ledger`, 9 forks): (1) source = per-changeset `ChangesetView`, committed changesets built via `DiffState::from_committed` (empty staged/unstaged sub-models); (2) mode = derived `is_committed` + targeted guards, leaning on the existing `effective_zoom` collapse (empty sub-diffs → combined-only for free); (3) outline = left side pane, all four modes (flat/tree/stack/stack-tree); (4) load = hybrid (eager per-changeset `DiffState`, lazy per-file `FileView`); (5) nav = continuous `]f`/`[f` across the stack + `]c`/`[c` changeset jumps; (6) open-at = honor the lib's `current` flag; (7) source scope = auto-detect Graphite else single uncommitted changeset (M2–M4 preserved, backward-compatible); (8) changeset indicator = new top winbar; (9) needs-restack = first-class glyph + amber color (the lib gives a real boolean, unlike the prototype's title-string suffix). — DONE (2026-07-07): shipped as FOUR changesets `m5-stack-source → m5-changeset-nav → m5-outline-core → m5-outline-tree`, each delegated to an `implementer` subagent and main-thread diff-read before the next landed. The M1 lib already provided `assemble_changesets` + the `diff_changeset` router, so M5 was almost entirely review-App wiring; the uncommitted layer becomes one changeset *inside* the stack, keeping all of M4's staging/zoom/attribution working on it while committed changesets render read-only. Two correctness fixes surfaced during implementation, neither in the plan: (a) a committed changeset's combined-role old side must read its `base` commit's tree, not live `HEAD` (`old_side_tree_for`); (b) skipping attribution for committed changesets is not just a guard — without it `Attribution::build(None, None)`'s empty sets miscolored every Add cell as "already staged" (dim), pinned by a render test. Acceptance met: dogfooded against this repo's own live 33-changeset Graphite stack via a PTY harness (winbar changeset counter, `]c`/`[c` nav, outline flat/stack/tree/stack-tree modes with correct tree guides, open-on-uncommitted-layer focus) — a clean exit, no panic, exercising the real `resolve_changesets`→`assemble_graphite` path the hand-built unit tests don't. Full workspace green (41 suites, 804 tests, 0 fail), clippy `-D warnings --all-targets --all-features` clean. Deferred: Git-inference (`StackModel::Git`) and explicit ref-range review (the broader "ref sources") — auto-detect ships Graphite-or-uncommitted only; a fixed 35-col outline with no narrow-terminal handling. - **M6 — comments + integration.** Comment store + `mcp` subcommand; `$NVIM`/`$EDITOR` edit jump; git-workon external dispatch + completion delegation. Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review. ## Orchestration notes From f50b4fd6d49bd3921c122c3fbb2fc129752b1372 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 14:21:07 -0400 Subject: [PATCH 042/203] docs(rfc): reorder and split remaining milestones into M6-M9 --- docs/rfc/workon-review.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index f164038..02a1c24 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -132,7 +132,10 @@ evidence, not to the conclusion. - **M3 — renderer + uncommitted source.** Port spike modules; wire changeset → parsed diff → SBS/inline render; file nav; the uncommitted source end-to-end. Acceptance: dogfood-able read-only review of a dirty worktree. — DONE (2026-07-06): combined-zoom read-only review with SBS + inline layouts, collapsed context gaps, word-diff emphasis, tree-sitter highlighting (spike's 8 grammars; syntect deferred), file/hunk nav; dogfooded against a dirty worktree. Port note: the spike's `compose_segments` had a latent first-match span-precedence bug that silently dropped word-level emphasis — fixed here (reverse-order lookup), pinned by a three-way bg test in `render.rs`. - **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. Design locked 2026-07-06 (plan artifact `iron-lattice`): (1) staging = prototype parity — verbs act only in unstaged/staged panes, combined refuses, direction = pane role (combined-native toggle deferred); (2) cursor-primary nav in all views, scroll derived; (3) full 4-state zoom (`split→combined→unstaged→staged`) with per-file `_gate` downgrade and stacked split panes (per-pane cursor, `w` focus), no collapse debounce; (4) runtime stays sync — poll `IndexSignature` on Tick, synchronous re-diff (no threads/notify dep); (5) queue enqueue+drain same beat, refresh, re-snapshot; (6) footer-swap for refusals/errors + discard confirm; (7) attribution via a new pure `attribute.rs` (membership sets keyed by lnum); (8) line selection in both layouts (inline one-sided, SBS row-pair). — DONE (2026-07-07): shipped as EIGHT changesets `m4-cursor → m4-zoom → m4-attribute → m4-notify → m4-refresh → m4-stage → m4-select → m4-watch` (staging split into hunk/file vs line selection; refresh pulled out as shared infra for stage + watch). Stack-reviewed continuously on the main thread; two real bugs caught by review, not by agent tests: (a) m4-zoom sub-view panes rendered worktree text where index text belonged — fixed with per-role blob sourcing (`read_index_blob`); (b) m4-select applied a multi-hunk line selection as N independent patches, which libgit2 rejects because each per-hunk patch's line numbering assumes the others are present — fixed by merging into ONE `PatchText` (`ops::apply_line_selections`), pinned by a line-shift tripwire test. Acceptance met: staging parity dogfooded against real git (stage/unstage/discard hunk/file/line, partial-hunk selection); index watcher confirmed live (external `git add` auto-refreshes on the next Tick — the watcher polls `.git/index`'s signature, so it catches index writes, not bare worktree edits, matching its name). Runtime stayed sync (no threads); combined-native staging toggle and spike `--dump`/`--bench` modes remain deferred. - **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). Design locked 2026-07-07 (plan artifact `cairn-ledger`, 9 forks): (1) source = per-changeset `ChangesetView`, committed changesets built via `DiffState::from_committed` (empty staged/unstaged sub-models); (2) mode = derived `is_committed` + targeted guards, leaning on the existing `effective_zoom` collapse (empty sub-diffs → combined-only for free); (3) outline = left side pane, all four modes (flat/tree/stack/stack-tree); (4) load = hybrid (eager per-changeset `DiffState`, lazy per-file `FileView`); (5) nav = continuous `]f`/`[f` across the stack + `]c`/`[c` changeset jumps; (6) open-at = honor the lib's `current` flag; (7) source scope = auto-detect Graphite else single uncommitted changeset (M2–M4 preserved, backward-compatible); (8) changeset indicator = new top winbar; (9) needs-restack = first-class glyph + amber color (the lib gives a real boolean, unlike the prototype's title-string suffix). — DONE (2026-07-07): shipped as FOUR changesets `m5-stack-source → m5-changeset-nav → m5-outline-core → m5-outline-tree`, each delegated to an `implementer` subagent and main-thread diff-read before the next landed. The M1 lib already provided `assemble_changesets` + the `diff_changeset` router, so M5 was almost entirely review-App wiring; the uncommitted layer becomes one changeset *inside* the stack, keeping all of M4's staging/zoom/attribution working on it while committed changesets render read-only. Two correctness fixes surfaced during implementation, neither in the plan: (a) a committed changeset's combined-role old side must read its `base` commit's tree, not live `HEAD` (`old_side_tree_for`); (b) skipping attribution for committed changesets is not just a guard — without it `Attribution::build(None, None)`'s empty sets miscolored every Add cell as "already staged" (dim), pinned by a render test. Acceptance met: dogfooded against this repo's own live 33-changeset Graphite stack via a PTY harness (winbar changeset counter, `]c`/`[c` nav, outline flat/stack/tree/stack-tree modes with correct tree guides, open-on-uncommitted-layer focus) — a clean exit, no panic, exercising the real `resolve_changesets`→`assemble_graphite` path the hand-built unit tests don't. Full workspace green (41 suites, 804 tests, 0 fail), clippy `-D warnings --all-targets --all-features` clean. Deferred: Git-inference (`StackModel::Git`) and explicit ref-range review (the broader "ref sources") — auto-detect ships Graphite-or-uncommitted only; a fixed 35-col outline with no narrow-terminal handling. -- **M6 — comments + integration.** Comment store + `mcp` subcommand; `$NVIM`/`$EDITOR` edit jump; git-workon external dispatch + completion delegation. Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review. +- **M6 — git-workon CLI integration.** Ordered first: dependency-free, lowest-risk, and it unlocks dogfooding every later milestone through the real `git workon review` entry point (not `cargo run`). Cargo-style external-subcommand dispatch — `git-workon`'s unknown subcommand execs `git-workon-` on PATH with args passed through (none exists today; `Cmd` is a closed enum), so `git workon review` works via git's native `git-*` dispatch. Plus completion delegation: the review binary gains `CompleteEnv` (its `Cli` is currently empty), and git-workon's dynamic completer enumerates `git-workon-*` on PATH and delegates post-subcommand completion via `COMPLETE= git-workon-review -- `. Acceptance: `git workon review` dispatches with args through; tab-completion delegates to the review binary. +- **M7 — review comments.** On-disk comment store (`.review/`, JSON-or-sqlite; both deps already in the workspace) keyed to changeset/path/side/line, with a **rebase-survival anchoring strategy** — the central greenfield fork (the frozen prototype has *no* comment store, MCP, or editor-jump: all three are designed from scratch; it only hands us the `(changeset_id, path, side, lnum)` location model with `head_ref ∈ {SHA, WORKTREE, INDEX}` and no re-anchoring precedent). Plus TUI comment UX: create a comment on a diff line, view inline/in a pane, mark resolved, store-watch refresh. Acceptance: a human reviews a changeset, leaves comments pinned to lines, and they persist + re-anchor across a diff refresh (manual `r` / Tick). Design the store schema with the eventual MCP projection (M9) in mind. +- **M8 — edit flow.** Editor-jump from a diff line to the file on disk — embedded `nvim --server $NVIM --remote + `, standalone `$EDITOR + ` (detect via `$NVIM`); file watcher refreshes the diff (and re-anchors comments) on external save — port the prototype's debounced repo-root watcher behavior (`FocusGained` fallback, viewport-preserving refresh, selection clamp; the Neovim mechanism doesn't translate, the behavior does). Ordered right after comments so watch-refresh and comment re-anchoring co-develop and stress-test the M7 anchor model immediately. Acceptance: jump opens the right file+line; saving refreshes the diff without losing viewport or comment anchors. +- **M9 — MCP agent loop.** `mcp` stdio subcommand exposing review state over MCP so an agent closes the loop (list comments, mark addressed; TUI reflects). Deliberately last so the cross-cutting MCP-stack commitment (crate — `rmcp` vs hand-rolled JSON-RPC-over-stdio — transport, error mapping) is made once, informed by both surfaces: decide **review-comment-only vs a unified `git-workon` MCP** that also exposes the worktree tools from `agent-integration.md` Model C (`worktree_create`/`list`/`find`/`remove`/`create_from_pr`, a git-workon-lib concern). Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review. ## Orchestration notes From 6ab670c7775299a09f03f30e4245aba6fadf56dd Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 15:02:23 -0400 Subject: [PATCH 043/203] feat(review): respond to COMPLETE dynamic-completion protocol --- Cargo.lock | 2 ++ git-workon-review/Cargo.toml | 2 ++ git-workon-review/src/main.rs | 8 +++++++- git-workon-review/tests/cli.rs | 20 ++++++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 85a03b2..699a1fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -960,7 +960,9 @@ name = "git-workon-review" version = "0.1.0" dependencies = [ "assert_cmd", + "assert_fs", "clap", + "clap_complete", "crossterm", "git-workon-fixture", "git-workon-lib", diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index 3ee08fc..e1d2596 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -33,6 +33,7 @@ vendored = ["git-workon-lib/vendored", "git2/vendored-libgit2", "git2/vendored-o [dependencies] clap.workspace = true +clap_complete.workspace = true crossterm.workspace = true git-workon-lib.workspace = true git2.workspace = true @@ -57,5 +58,6 @@ dist = false [dev-dependencies] assert_cmd.workspace = true +assert_fs.workspace = true git-workon-fixture.workspace = true predicates.workspace = true diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 09635a5..49607ce 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -1,6 +1,7 @@ mod tui; -use clap::Parser; +use clap::{CommandFactory, Parser}; +use clap_complete::env::CompleteEnv; use git2::Repository; use miette::{IntoDiagnostic, Result}; use workon_review::acquire::{diff_changeset, resolve_changesets}; @@ -12,6 +13,11 @@ use workon_review::app::{App, ChangesetView}; struct Cli {} fn main() -> Result<()> { + // Respond to the `COMPLETE=` dynamic-completion protocol before anything else — mirrors + // git-workon's own entry point. Exits early when `COMPLETE` is set; a no-op otherwise. This is + // what lets git-workon delegate `git workon review ` completion here (M6 CS3). + CompleteEnv::with_factory(Cli::command).complete(); + Cli::parse(); let repo = Repository::discover(".").into_diagnostic()?; diff --git a/git-workon-review/tests/cli.rs b/git-workon-review/tests/cli.rs index 28b9c88..fbec3c5 100644 --- a/git-workon-review/tests/cli.rs +++ b/git-workon-review/tests/cli.rs @@ -27,3 +27,23 @@ fn help_shows_usage_and_succeeds() { .success() .stdout(predicate::str::contains("git-workon-review")); } + +/// The binary answers the `COMPLETE=` dynamic-completion protocol (clap_complete's +/// `CompleteEnv`), so git-workon can delegate `git workon review ` completion to it (M6 CS3). +/// The review `Cli` has no args of its own yet, so the completer generates no candidates and exits +/// 2 ("no completion generated") — but the load-bearing contract is that `COMPLETE` mode +/// short-circuits into the completer *before* repository discovery or the TUI. Running from an +/// empty non-repo dir makes that concrete: an unwired binary would instead fail repo discovery; +/// getting clap_complete's own exit path proves the responder is in place. When M7 gives the +/// binary a real subcommand (`mcp`), upgrade this to assert that candidate. +#[test] +fn responds_to_complete_env_protocol_before_repo_discovery() { + let non_repo = assert_fs::TempDir::new().unwrap(); + let mut cmd = cargo_bin_cmd!("git-workon-review"); + cmd.env("COMPLETE", "bash") + .current_dir(&non_repo) + .args(["--", "git-workon-review", ""]) + .assert() + .code(2) + .stderr(predicate::str::contains("completion")); +} From 056fff8374cbc25ac60a091273ca703101d6c999 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 15:24:23 -0400 Subject: [PATCH 044/203] docs(rfc): mark M6 done, unify MCP as git workon mcp --- docs/rfc/workon-review.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 02a1c24..dbee05d 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -132,10 +132,10 @@ evidence, not to the conclusion. - **M3 — renderer + uncommitted source.** Port spike modules; wire changeset → parsed diff → SBS/inline render; file nav; the uncommitted source end-to-end. Acceptance: dogfood-able read-only review of a dirty worktree. — DONE (2026-07-06): combined-zoom read-only review with SBS + inline layouts, collapsed context gaps, word-diff emphasis, tree-sitter highlighting (spike's 8 grammars; syntect deferred), file/hunk nav; dogfooded against a dirty worktree. Port note: the spike's `compose_segments` had a latent first-match span-precedence bug that silently dropped word-level emphasis — fixed here (reverse-order lookup), pinned by a three-way bg test in `render.rs`. - **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. Design locked 2026-07-06 (plan artifact `iron-lattice`): (1) staging = prototype parity — verbs act only in unstaged/staged panes, combined refuses, direction = pane role (combined-native toggle deferred); (2) cursor-primary nav in all views, scroll derived; (3) full 4-state zoom (`split→combined→unstaged→staged`) with per-file `_gate` downgrade and stacked split panes (per-pane cursor, `w` focus), no collapse debounce; (4) runtime stays sync — poll `IndexSignature` on Tick, synchronous re-diff (no threads/notify dep); (5) queue enqueue+drain same beat, refresh, re-snapshot; (6) footer-swap for refusals/errors + discard confirm; (7) attribution via a new pure `attribute.rs` (membership sets keyed by lnum); (8) line selection in both layouts (inline one-sided, SBS row-pair). — DONE (2026-07-07): shipped as EIGHT changesets `m4-cursor → m4-zoom → m4-attribute → m4-notify → m4-refresh → m4-stage → m4-select → m4-watch` (staging split into hunk/file vs line selection; refresh pulled out as shared infra for stage + watch). Stack-reviewed continuously on the main thread; two real bugs caught by review, not by agent tests: (a) m4-zoom sub-view panes rendered worktree text where index text belonged — fixed with per-role blob sourcing (`read_index_blob`); (b) m4-select applied a multi-hunk line selection as N independent patches, which libgit2 rejects because each per-hunk patch's line numbering assumes the others are present — fixed by merging into ONE `PatchText` (`ops::apply_line_selections`), pinned by a line-shift tripwire test. Acceptance met: staging parity dogfooded against real git (stage/unstage/discard hunk/file/line, partial-hunk selection); index watcher confirmed live (external `git add` auto-refreshes on the next Tick — the watcher polls `.git/index`'s signature, so it catches index writes, not bare worktree edits, matching its name). Runtime stayed sync (no threads); combined-native staging toggle and spike `--dump`/`--bench` modes remain deferred. - **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). Design locked 2026-07-07 (plan artifact `cairn-ledger`, 9 forks): (1) source = per-changeset `ChangesetView`, committed changesets built via `DiffState::from_committed` (empty staged/unstaged sub-models); (2) mode = derived `is_committed` + targeted guards, leaning on the existing `effective_zoom` collapse (empty sub-diffs → combined-only for free); (3) outline = left side pane, all four modes (flat/tree/stack/stack-tree); (4) load = hybrid (eager per-changeset `DiffState`, lazy per-file `FileView`); (5) nav = continuous `]f`/`[f` across the stack + `]c`/`[c` changeset jumps; (6) open-at = honor the lib's `current` flag; (7) source scope = auto-detect Graphite else single uncommitted changeset (M2–M4 preserved, backward-compatible); (8) changeset indicator = new top winbar; (9) needs-restack = first-class glyph + amber color (the lib gives a real boolean, unlike the prototype's title-string suffix). — DONE (2026-07-07): shipped as FOUR changesets `m5-stack-source → m5-changeset-nav → m5-outline-core → m5-outline-tree`, each delegated to an `implementer` subagent and main-thread diff-read before the next landed. The M1 lib already provided `assemble_changesets` + the `diff_changeset` router, so M5 was almost entirely review-App wiring; the uncommitted layer becomes one changeset *inside* the stack, keeping all of M4's staging/zoom/attribution working on it while committed changesets render read-only. Two correctness fixes surfaced during implementation, neither in the plan: (a) a committed changeset's combined-role old side must read its `base` commit's tree, not live `HEAD` (`old_side_tree_for`); (b) skipping attribution for committed changesets is not just a guard — without it `Attribution::build(None, None)`'s empty sets miscolored every Add cell as "already staged" (dim), pinned by a render test. Acceptance met: dogfooded against this repo's own live 33-changeset Graphite stack via a PTY harness (winbar changeset counter, `]c`/`[c` nav, outline flat/stack/tree/stack-tree modes with correct tree guides, open-on-uncommitted-layer focus) — a clean exit, no panic, exercising the real `resolve_changesets`→`assemble_graphite` path the hand-built unit tests don't. Full workspace green (41 suites, 804 tests, 0 fail), clippy `-D warnings --all-targets --all-features` clean. Deferred: Git-inference (`StackModel::Git`) and explicit ref-range review (the broader "ref sources") — auto-detect ships Graphite-or-uncommitted only; a fixed 35-col outline with no narrow-terminal handling. -- **M6 — git-workon CLI integration.** Ordered first: dependency-free, lowest-risk, and it unlocks dogfooding every later milestone through the real `git workon review` entry point (not `cargo run`). Cargo-style external-subcommand dispatch — `git-workon`'s unknown subcommand execs `git-workon-` on PATH with args passed through (none exists today; `Cmd` is a closed enum), so `git workon review` works via git's native `git-*` dispatch. Plus completion delegation: the review binary gains `CompleteEnv` (its `Cli` is currently empty), and git-workon's dynamic completer enumerates `git-workon-*` on PATH and delegates post-subcommand completion via `COMPLETE= git-workon-review -- `. Acceptance: `git workon review` dispatches with args through; tab-completion delegates to the review binary. -- **M7 — review comments.** On-disk comment store (`.review/`, JSON-or-sqlite; both deps already in the workspace) keyed to changeset/path/side/line, with a **rebase-survival anchoring strategy** — the central greenfield fork (the frozen prototype has *no* comment store, MCP, or editor-jump: all three are designed from scratch; it only hands us the `(changeset_id, path, side, lnum)` location model with `head_ref ∈ {SHA, WORKTREE, INDEX}` and no re-anchoring precedent). Plus TUI comment UX: create a comment on a diff line, view inline/in a pane, mark resolved, store-watch refresh. Acceptance: a human reviews a changeset, leaves comments pinned to lines, and they persist + re-anchor across a diff refresh (manual `r` / Tick). Design the store schema with the eventual MCP projection (M9) in mind. +- **M6 — git-workon CLI integration.** Ordered first: dependency-free, lowest-risk, and it unlocks dogfooding every later milestone through the real `git workon review` entry point (not `cargo run`). Cargo-style external-subcommand dispatch — `git-workon`'s unknown subcommand execs `git-workon-` on PATH with args passed through (none exists today; `Cmd` is a closed enum), so `git workon review` works via git's native `git-*` dispatch. Plus completion: the review binary gains `CompleteEnv` (its `Cli` is currently empty) so it is a `COMPLETE=` responder, and git-workon's dynamic completer enumerates `git-workon-*` on PATH and surfaces them as top-level subcommand candidates (so `git workon ` offers `review`). **Post-subcommand sub-delegation** (`git workon review ` → shell out to the review binary's completer) is **deferred, not built**: the review binary's `Cli` is currently empty (zero candidates), and MCP lands as `git workon mcp` (not a review subcommand — see M9), so there is nothing to delegate today. Its real trigger is *not* MCP — it's whenever the review binary gains its source-selector arg (`stack | uncommitted | | | pr-####`, the deferred v1 sources), whose values (refs, ranges, PR numbers) are genuinely completion-worthy. Wire delegation then, against that real surface; the review binary is already a `COMPLETE=` responder, so only the git-workon-side shell-out remains. Acceptance: `git workon review` dispatches with args through; `git workon ` lists external subcommands including `review`. DONE (2026-07-07): shipped as THREE changesets `m6-dispatch → m6-review-complete → m6-complete-enum` — (1) manual pre-parse PATH intercept (`dispatch.rs`), NOT clap `allow_external_subcommands` (which would break the flattened-`find.name` default-command routing); (2) review binary as `COMPLETE=` responder; (3) top-level external enumeration in the completer. Two seam facts surfaced: the clap_complete bash protocol needs `_CLAP_COMPLETE_INDEX` (word position) or it emits "no completion generated", and an empty `Cli` yields zero candidates (which is what made sub-delegation pointless to build). +- **M7 — review comments.** On-disk comment store (`.review/`, JSON-or-sqlite; both deps already in the workspace) keyed to changeset/path/side/line, with a **rebase-survival anchoring strategy** — the central greenfield fork (the frozen prototype has *no* comment store, MCP, or editor-jump: all three are designed from scratch; it only hands us the `(changeset_id, path, side, lnum)` location model with `head_ref ∈ {SHA, WORKTREE, INDEX}` and no re-anchoring precedent). Plus TUI comment UX: create a comment on a diff line, view inline/in a pane, mark resolved, store-watch refresh. Acceptance: a human reviews a changeset, leaves comments pinned to lines, and they persist + re-anchor across a diff refresh (manual `r` / Tick). **Comment-store home is a first-class M7 fork, not just its schema:** M9's `git workon mcp` (in the `git-workon` crate) must read comments, making git-workon a *second consumer* of the store — so it cannot live inside the review binary. It belongs in a lib both the review crate and git-workon can depend on (git-workon-lib, or a new shared crate). This reopens the RFC's deferred "no separate core crate until a second consumer exists" decision — resolve it here. - **M8 — edit flow.** Editor-jump from a diff line to the file on disk — embedded `nvim --server $NVIM --remote + `, standalone `$EDITOR + ` (detect via `$NVIM`); file watcher refreshes the diff (and re-anchors comments) on external save — port the prototype's debounced repo-root watcher behavior (`FocusGained` fallback, viewport-preserving refresh, selection clamp; the Neovim mechanism doesn't translate, the behavior does). Ordered right after comments so watch-refresh and comment re-anchoring co-develop and stress-test the M7 anchor model immediately. Acceptance: jump opens the right file+line; saving refreshes the diff without losing viewport or comment anchors. -- **M9 — MCP agent loop.** `mcp` stdio subcommand exposing review state over MCP so an agent closes the loop (list comments, mark addressed; TUI reflects). Deliberately last so the cross-cutting MCP-stack commitment (crate — `rmcp` vs hand-rolled JSON-RPC-over-stdio — transport, error mapping) is made once, informed by both surfaces: decide **review-comment-only vs a unified `git-workon` MCP** that also exposes the worktree tools from `agent-integration.md` Model C (`worktree_create`/`list`/`find`/`remove`/`create_from_pr`, a git-workon-lib concern). Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review. +- **M9 — MCP agent loop (`git workon mcp`).** A **first-class `mcp` subcommand of the main `git-workon` binary** (not a review subcommand) starting one stdio MCP server that **bridges both domains**: git-workon-lib worktree tools (`agent-integration.md` Model C — `worktree_create`/`list`/`find`/`remove`/`create_from_pr`) *and* the review comment store (list comments, mark addressed; the TUI reflects changes). One server, one config entry, both capabilities — the unified direction (superseding the earlier "review-comment-only vs unified" fork and the RFC's original `git-workon-review mcp` framing). Deliberately last so the cross-cutting MCP-stack commitment (crate — `rmcp` vs hand-rolled JSON-RPC-over-stdio — transport, error mapping) is made once across both surfaces, and because it depends on the M7 comment store living in a shared lib (see M7). Consequence: `git-workon` gains a dependency on the comment-store lib; the worktree-MCP no longer wants a separate `git-workon-mcp` crate. Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review — plus worktree tools served from the same `git workon mcp`. ## Orchestration notes From f54364e5afa4cb617cd8f57747ff86331a53d501 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 16:47:41 -0400 Subject: [PATCH 045/203] fix(review): render TUI to /dev/tty so stdout capture doesn't hang --- git-workon-review/src/tui.rs | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 3c98e45..212d5c2 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -10,7 +10,8 @@ //! comparing [`workon_review::refresh::IndexSignature`] and re-diffing in place via //! [`App::on_tick`] when it changes. No threads, no `mpsc`, no new deps. -use std::io::{self, Stdout}; +use std::fs::File; +use std::io::{self, Write}; use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; @@ -255,15 +256,32 @@ fn update(app: &mut App, pending: &mut Option, event: AppEvent) -> bool { } } +/// Open the controlling terminal (`/dev/tty`) for writing, falling back to stdout when there is +/// none (a pipe/CI with no tty). The TUI renders here rather than to stdout so it stays usable +/// inside a shell command substitution: the `workon` wrapper function captures `git workon`'s +/// stdout to `cd` into a printed path, and `git workon review` dispatches to this TUI — if the +/// alternate screen went to the captured stdout, nothing would reach the terminal and the wrapper +/// would hang. Writing to `/dev/tty` keeps stdout clean (this mirrors crossterm, which already +/// reads *input* events from `/dev/tty` on unix). The boxed writer unifies the two branches so the +/// rest of the lifecycle is one type. +fn terminal_writer() -> Box { + match File::options().write(true).open("/dev/tty") { + Ok(tty) => Box::new(tty), + Err(_) => Box::new(io::stdout()), + } +} + /// Install a panic hook that restores the terminal (raw mode off, leave alternate screen) before /// the default hook prints the panic — without this, a panic mid-review leaves the user's shell /// in alternate-screen raw mode with no visible message. Ported from the spike's -/// `install_panic_hook`. +/// `install_panic_hook`. Restores on `/dev/tty` (where the alternate screen was entered), falling +/// back to stdout — matching [`terminal_writer`]. fn install_panic_hook() { let default_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen); + let mut out = terminal_writer(); + let _ = execute!(out, LeaveAlternateScreen); default_hook(info); })); } @@ -273,9 +291,9 @@ fn install_panic_hook() { pub fn run(app: &mut App) -> io::Result<()> { install_panic_hook(); enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); + let mut out = terminal_writer(); + execute!(out, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(out); let mut terminal = Terminal::new(backend)?; let result = event_loop(&mut terminal, app); @@ -287,7 +305,10 @@ pub fn run(app: &mut App) -> io::Result<()> { result } -fn event_loop(terminal: &mut Terminal>, app: &mut App) -> io::Result<()> { +fn event_loop( + terminal: &mut Terminal>, + app: &mut App, +) -> io::Result<()> { let mut pending: Option = None; let mut quit = false; From 6f88f16bcd1bf35f2b060612e80548678900f053 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 23:41:47 -0400 Subject: [PATCH 046/203] docs(review): lock M6.5 usability design (ADR-028/029) --- .../028-review-git-native-config-schema.md | 96 ++++++++++++ docs/adr/029-review-theming-base16-hybrid.md | 100 ++++++++++++ docs/plans/review-usability-pass.md | 147 ++++++++++++++++++ docs/rfc/workon-review.md | 1 + 4 files changed, 344 insertions(+) create mode 100644 docs/adr/028-review-git-native-config-schema.md create mode 100644 docs/adr/029-review-theming-base16-hybrid.md create mode 100644 docs/plans/review-usability-pass.md diff --git a/docs/adr/028-review-git-native-config-schema.md b/docs/adr/028-review-git-native-config-schema.md new file mode 100644 index 0000000..52e9d34 --- /dev/null +++ b/docs/adr/028-review-git-native-config-schema.md @@ -0,0 +1,96 @@ +# 028 — Review TUI Config: Git-Native Per-View Namespaces + +## Context + +The review TUI (`git-workon-review`) grew its keybindings and colors as hardcoded +values during M3–M5: a `match` in `tui.rs` for keys, a block of `const … Color::Rgb(…)` +atop `render.rs` for theming. Making either user-configurable needs a config home, and +the review binary reads no config today (`struct Cli {}` is empty). + +[ADR-006](006-git-native-config.md) already commits the tool to git-native config under +the `workon.*` namespace — no bespoke file format, git's layered precedence +(local → global → system), multivar for lists. The open question was whether a *keymap* +fits that model, since a keymap is many key→action entries. Three shapes were considered: + +1. A dedicated `review.toml` (nested keymap syntax, in-tree shareable) — but a second + config system, against ADR-006's one-config-system principle, needs a new loader and + precedence layer. +2. Value-side multivar `workon.review.bind = "key=action"` — git-native, but multivar + *accumulates* across layers, forcing us to reimplement override precedence and + last-wins dedup by hand and invent an unbind sentinel. +3. Action-as-key, per-view namespaces (chosen). + +The keymap is also context-dependent: the same key differs by view (`j` is cursor-down in +the diff pane, outline-move-down in the outline), and some bindings are two-key chords +(`]f`). Whitespace and special keys (Tab, Enter, Esc, arrows, **space**) have no safe +literal form. + +## Decision + +All review config lives under `workon.review.*` in git config, extending ADR-006. Config +is stored **action-as-key** in **per-view subsections**: + +``` +workon.review.theme = dark ; global, non-view +workon.review..bind. = "" ; a keymap entry +workon.review.. = ; view config +``` + +- **View** ∈ `diff`, `outline`; a bare `workon.review.bind.` is the **global** + keymap (active in every view). Git parses `workon.review.diff.bind.stage-hunk` as + section `workon`, subsection `review.diff.bind`, name `stage-hunk` — dotted subsections + are legal and case-sensitive (always lowercase here). +- **The action is the config variable; the keys are the value.** Each binding is therefore + an ordinary *single-valued* variable, so git's native precedence does all override work: + setting it replaces (local beats global beats system via `config.get_string()`), and an + empty value unbinds. No custom layering, no sentinel. Defaults live in code; a git entry + overrides that action's default. Action names qualify as git variable names (alphanumeric + + `-`, alpha-initial): `stage-hunk`, `next-file`, `toggle-outline`, … +- **Value = space-separated key tokens** (an action may have several keys, e.g. + `cursor-down = "j down"`). Replace, not append: setting a binding states exactly what + triggers it. Token grammar: + - **Reserved symbolic names (win over literals):** `space tab enter esc up down left + right home end pageup pagedown backspace delete backtab f1`–`f12`. + - **Modifier prefix:** `ctrl-`, `alt-`, `shift-` on any token (`ctrl-d`, `ctrl-space`). + - **Literal:** otherwise printable chars — length 1 is one key (`s`, `=`), length >1 is a + chord (`]f`). A token is matched against reserved words and the modifier grammar first, + literal only if neither matches, so `space` is always the spacebar. +- **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. +- **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 + collisions (one key claimed by two actions in a view → footer warning + deterministic + winner; defaults never collide, so this only fires on user config). +- **Not rebindable:** the confirm modal (`y`/`n`/`Esc`) and the whole `Esc` precedence + 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. + +## Consequences + +- One config system across the whole tool; users already know `git config`. Global + preferences in `~/.gitconfig`, per-repo in `.git/config`, standard layering — inherited + from ADR-006 for free. +- Override and unbind require **no resolver logic** — they are native git-config semantics. + This is the primary reason action-as-key beat value-side `key=action`. +- Key names and action names become a **compatibility surface**: once users write + `workon.review.diff.bind.stage-hunk`, renaming that action or restructuring the namespace + breaks their config. Action names are therefore part of the stable API, and the help + overlay renders from the same enumerable action set. +- Like all git-native config (ADR-006), review config is **not checked into the repo**, so a + team cannot ship a shared review keymap/theme in-tree. Accepted: this is a + personal-productivity TUI. +- The per-view namespace gives previously-hardcoded view settings (outline width — M5 + deferred narrow-terminal handling — outline mode, diff layout/zoom defaults) a natural + home without a second design pass. +- Adding a rebindable action = adding it to the enumerable action set (code default + + dispatch + help entry); it is automatically configurable, validated, and documented. + +## References + +- [ADR-006](006-git-native-config.md) — git-native config under `workon.*` this extends +- `docs/rfc/workon-review.md` — RFC; this is the everyday-usability pass inserted ahead of M7 +- `git-workon-review/src/tui.rs` — current hardcoded keymap (`map_key`) being replaced +- `git-workon-review/src/render.rs` — current hardcoded palette (`const … Color::Rgb`) — see the theming decision diff --git a/docs/adr/029-review-theming-base16-hybrid.md b/docs/adr/029-review-theming-base16-hybrid.md new file mode 100644 index 0000000..97c6559 --- /dev/null +++ b/docs/adr/029-review-theming-base16-hybrid.md @@ -0,0 +1,100 @@ +# 029 — Review TUI Theming: Hybrid base16, Render-Time Resolution, Terminal-Derived `auto` + +## Context + +The review TUI's colors were hardcoded during M3–M5: a `const … Color::Rgb(…)` block +atop `render.rs` (dark-only) and a parallel `HIGHLIGHT_NAMES`/`HIGHLIGHT_COLORS` pair in +`highlight.rs`. The everyday-usability pass (ahead of M7, see [ADR-028](028-review-git-native-config-schema.md)) +adds built-in light/dark theming and terminal adaptivity. Four things had to be resolved: +the color *philosophy* (respect the terminal's 16 ANSI colors vs. ship tuned truecolor), +the theme *primitive*, the *mechanism* by which a theme reaches syntax highlighting, and +what "adapt to the terminal" concretely means. + +Key constraint: diff readability depends on a **truecolor gradient** — `BG_*_SUBTLE` vs +`BG_*_STRONG` and their staged variants sit a few RGB shades apart, and that gradient is +how word-level emphasis and staged-vs-unstaged attribution read at a glance. The 16-color +ANSI palette has no equivalent, so pure "inherit the terminal's ANSI colors" (which would +self-adapt for free) was rejected — it regresses the readability that is the tool's point. + +A second discovery shaped the primitive: `highlight.rs` is *already* a base16 template. +Its comment says the palette is "in the same family as base16-eighties.dark," and the +`C_RED/ORANGE/YELLOW/GREEN/CYAN/BLUE/PURPLE` consts are base08–base0E, mapped to captures +per the base16 spec's role conventions (`keyword → base0E`, `string → base0B`, +`function → base0D`, `comment → base03`, …). The capture→slot template already exists and +is spec-conformant. + +## Decision + +**Philosophy — hybrid, split on "does this color sit on a tinted background?"** +- **On a tint → base16 truecolor (theme-controlled):** diff add/del subtle/strong + staged + variants, cursor, selection, and **syntax**. Contrast is guaranteed because foreground and + background come from the *same* scheme. +- **Chrome, not on a tint → ANSI-named (`Color::Gray`/`DarkGray`/…):** gutter, borders, + footer, dim labels, status. These inherit the terminal palette, self-adapt light/dark, and + are **probe-independent** (work even when terminal-derivation fails). Half already are + ANSI-named today. + +**Primitive — the theme is a base16 scheme.** A `Theme` holds the 16 slots +(base00–07 mono ramp + base08–0F accents). Syntax uses the accents via the existing +capture→slot template. Diff-bg tints are **derived**, not authored: blend base08 +(red / spec "Diff Deleted") and base0B (green / spec "Diff Inserted") toward base00 (bg) +using the existing `tint_toward` helper (`render.rs`). Syntax and diff tints therefore come +from one scheme and stay coordinated by construction. + +**Mechanism — resolve color at render time, not in the highlight phase.** +- `HIGHLIGHT_NAMES` stays global/const: it defines the capture *index space* bound by + `config.configure()` and is theme-invariant. +- `FgSpan` carries the **capture index** (semantic role), not a resolved `Color`. The + highlight phase (`highlight.rs:283`) records the index instead of looking up a color. +- Render resolves `index → Color` against the active `Theme` (`theme.slot[idx]`), in the + same place it resolves diff tints and cursor/selection. One theme-application site; + syntax and background contrast are reasoned about together. +- Consequence: the expensive tree-sitter pass is theme-free and cacheable — a theme switch + recolors by re-rendering, without re-parsing. + +**Selection — `workon.review.theme = auto | dark | light`** (git config, per ADR-028; +`auto` is the default). +- **`auto` = terminal-derived.** Probe the terminal for its palette (`OSC 4;n;?` for + n=0–15, `OSC 10/11` for fg/bg), populate the 16 slots from the real RGB, and derive tints + from the probed base00/08/0B. `auto` *means* terminal-derivation and nothing else — it is + not a placeholder for a curated pick (an earlier `COLORFGBG`-picks-curated design was + rejected precisely because it would change `auto`'s meaning once the probe landed). +- **`dark` / `light` = curated base16 schemes** — explicit overrides and the probe-failure + fallback. `dark` is the current eighties.dark values; `light` is a published base16 light + scheme's 16 hexes (pasted, not hand-invented). + +**Terminal derivation specifics.** +- ANSI-16 cannot fill 6 base16 slots (base01, base02, base04, base06, base09, base0F), so + those are **synthesized**: ramp intermediates by interpolation (base01/02 from base00→03, + base04/06 from base03→05→07), base09 (orange) by blending base08+base0A, base0F from + base09/base08. The diff-critical slots (base00/08/0B) are always real, so tint quality is + preserved; the loss is secondary accents. +- The probe runs at startup on the controlling `/dev/tty` (the TUI already renders there — + see `tui.rs`), in raw mode, reading replies with a short timeout. **Failure degrades + gracefully:** per-slot fallback to the curated scheme's slot; total failure falls back to + the curated scheme chosen by background luminance if `OSC 11` answered, else `dark`. + tmux/screen/ssh non-response is handled by the timeout, never a hang. + +## Consequences + +- Light/dark ships as curated base16 schemes now; **terminal-derivation is first-class from + the start**, not deferred. `auto` never has to change meaning later. +- Because color resolves late as `theme.slot[idx]`, the slot *source* is pluggable — a future + user-supplied base16 scheme (`theme = ` / a scheme file, the deferred + "user-configurable colors" tier) is additive, no renderer change. +- The OSC probe is the single most terminal-fragile component; its blast radius is contained + by the timeout + curated fallback, so a hostile terminal yields a correct curated theme, + never a hang or a broken palette. +- Adding a syntax capture = adding it to `HIGHLIGHT_NAMES` + the capture→slot template; it is + automatically themed by every scheme. +- `render.rs` and `highlight.rs` both change: the `const` palette becomes a `Theme` threaded + to render; `FgSpan` loses its `Color` field in favor of a capture index. Existing render + tests that assert concrete colors must resolve through a fixed test `Theme`. + +## References + +- [ADR-028](028-review-git-native-config-schema.md) — `workon.review.theme` config key +- [ADR-006](006-git-native-config.md) — git-native config this builds on +- `git-workon-review/src/highlight.rs` — existing base16-conformant capture→slot template +- `git-workon-review/src/render.rs` — `const` palette + `tint_toward` blend helper being generalized +- base16 styling spec — slot role conventions (base08 red/Diff-Deleted, base0B green/Diff-Inserted, base0E keywords, …) diff --git a/docs/plans/review-usability-pass.md b/docs/plans/review-usability-pass.md new file mode 100644 index 0000000..162ea1b --- /dev/null +++ b/docs/plans/review-usability-pass.md @@ -0,0 +1,147 @@ +# Plan — Review TUI Everyday-Usability Pass (M6.5) + +Design locked 2026-07-07. Decisions live in **[ADR-028](../adr/028-review-git-native-config-schema.md)** +(config schema + keymap) and **[ADR-029](../adr/029-review-theming-base16-hybrid.md)** +(theming). This doc is the *execution* plan: what lands, in what order, how each unit is +verified. Read both ADRs before implementing — this plan does not restate their rationale. + +Comments (M7) are deprioritized behind this pass. Goal: make the review TUI usable for +everyday review work — configurable keybindings, discoverable help, real theming. + +## Scope (four tracks) + +1. **Keybindings** — action registry (action → default keys, description, view); git-config + loading of `workon.review..bind.`; token-grammar parser; per-view + resolution with validation + collision detection; **defaults unchanged** (decided: + configurability + discoverability is the fix, not a keymap redesign). +2. **Help surface** — persistent curated per-view footer + `?` overlay (focused view + global + bindings), new `toggle-help` action. +3. **Theming** — base16 `Theme` primitive; render-time color resolution (`FgSpan` carries a + capture index, not a `Color`); hybrid boundary (on-tint = base16 truecolor, chrome = + ANSI-named); derived diff tints; `workon.review.theme = auto|dark|light`; + terminal-derivation OSC probe for `auto` with curated fallback; curated dark + light. +4. **View-config** — `workon.review.outline.width|mode`, `workon.review.diff.layout|zoom` + read from config with current values as defaults. + +## Changeset partition (Graphite stack) + +Two independent tracks fan out from the shared config reader (CS1), plus view-config off CS1. +Each unit is land-alone (green + valuable on `main` by itself) and standalone-review. + +``` +main + └─ uc-review-config CS1 ── shared git-config reader + ├─ uc-keymap CS2 ── configurable per-view keymaps (keybinding track) + │ └─ uc-help CS3 ── footer + ? overlay + ├─ uc-theme-base16 CS4 ── Theme primitive + render-time resolution (dark only, no visible change) + │ └─ uc-theme-light CS5 ── curated light + theme=dark|light + │ └─ uc-theme-auto CS6 ── terminal-derivation probe + theme=auto default + └─ uc-view-config CS7 ── outline.width/mode, diff.layout/zoom +``` + +Order of landing: CS1 → (CS2 → CS3) and (CS4 → CS5 → CS6) and CS7. The keymap and theming +subtrees are independent after CS1; land in either interleaving. Main-thread diff-read each +before the next lands (per the working style). + +### CS1 — `ReviewConfig` reader +- **Decision:** the review binary reads git config for the first time. Mirror + `git-workon-lib/src/config.rs`'s `WorkonConfig` pattern: read via `repo.config()` (the + `App` already owns a `Repository` — see `app.rs`). New module `git-workon-review/src/config.rs`. +- Provide typed getters for the keys this pass introduces (bindings, theme, view settings). + Reuse git2 `Config::get_string`/`get_bool`/`get_i64`/`multivar` as `WorkonConfig` does. +- **No behavior change yet** — just the reader + tests against a fixture repo config. +- Verify: unit tests reading `workon.review.*` from a `FixtureBuilder` repo (both a set and + an unset/default case). Load `/docs testing` first; use FixtureBuilder + predicates. + +### CS2 — Action registry + configurable keymaps +- **Decision:** ADR-028. Replace the hardcoded `map_key` match (`tui.rs`) with a + registry-driven dispatch. +- Build the **action registry**: one table `action → (default keys, human description, view ∈ + {global, diff, outline})`. This is the single source of truth for defaults, validation, + help text. The existing `Action` enum is the action set; extend, don't fork it. +- **Token-grammar parser** (ADR-028): reserved symbolic names (incl. `space`, `tab`, `enter`, + `esc`, arrows, `backtab`, `f1`–`f12`), modifier prefixes (`ctrl-`/`alt-`/`shift-`), literal + chars, chords (`]f`). Reserved-word-wins disambiguation. +- **Load + invert:** read every `workon.review.*.bind.*` var (via CS1), split values into key + tokens, build per-view `key → action` maps. A git entry overrides that action's default + (native single-value precedence — no custom layering). Empty value = unbind. +- **Validation + collisions:** unknown `bind.` → footer warning (action set is + enumerable); a key claimed by two actions in one view → footer warning + deterministic + winner. Defaults never collide. +- **Not rebindable, keep hardcoded:** confirm modal (`y`/`n`/`Esc`) and the whole `Esc` + precedence cascade (`tui.rs` `update`). Do not route these through the registry. +- Verify: parser unit tests (each token class incl. `space`, a chord, an unbind, an unknown + action, a collision); a dispatch test asserting a rebind takes effect. `map_key`'s existing + behavior tests must still pass (defaults unchanged). + +### CS3 — Help surface +- **Decision:** persistent curated per-view footer + `?` overlay targeting the focused view. +- **Footer:** always-visible one line of ~5–7 **hand-curated** keys for the focused + context (diff vs outline), rendered from the resolved map + registry descriptions. Updates + on focus/mode change. A transient notice **temporarily replaces** it (notices already clear + on next keypress — `tui.rs` `update`), so no second line. +- **`?` overlay:** new global action `toggle-help` bound to `?`. Centered modal listing the + **focused view's** bindings **+ global** bindings (what's live right now), grouped, from the + resolved registry. Renders the *active* map so user rebinds show. +- Curation: pick the footer key set per view deliberately (this is the "feels learnable" + lever). Diff: nav + stage/discard + outline + help. Outline: nav + open + mode + back. +- Verify: overlay renders resolved (rebound) keys; footer swaps with a notice and returns; + instrument via a log-file + expect harness, NOT ratatui frame grepping (see the TUI-dogfood + memory). + +### CS4 — base16 `Theme` primitive + render-time resolution +- **Decision:** ADR-029. Largest mechanical unit; **behavior-preserving** (dark stays + pixel-identical), so land-alone with no user-visible change. +- Introduce `struct Base16 { base00..base0F }` / `Theme`. Re-express the current `render.rs` + `const` palette + `highlight.rs` accents as the **dark** base16 instance (the existing + values ARE base08–0E + a ramp — see ADR-029). Derive diff tints via the existing + `tint_toward` from base08/base0B toward base00. +- **Hybrid boundary:** on-tint colors resolve from `Theme` slots; chrome stays ANSI-named + (`Color::Gray`/`DarkGray`/…) — several already are. +- **Mechanism:** change `FgSpan` to carry the **capture index** (not a `Color`); + `highlight.rs:283` records `idx`; render resolves `theme.slot[idx]` alongside tint/cursor. + `HIGHLIGHT_NAMES` stays const (index space). Thread `&Theme` into render. +- Verify: existing render/highlight tests that assert concrete colors now resolve through a + fixed test `Theme` (dark) — same asserted colors. Full workspace green. This is the + regression gate that the refactor changed nothing. + +### CS5 — Curated light scheme + `theme = dark|light` +- Add the **light** base16 instance (paste a published base16 light scheme's 16 hexes — do + NOT hand-invent; ADR-029). Wire `workon.review.theme` (via CS1) to select dark/light; + derived tints recompute for light automatically. +- Verify: `theme=light` selects the light instance; tints derive; a render test at light. + +### CS6 — `theme = auto` terminal-derivation probe +- **Decision:** ADR-029. The single most terminal-fragile unit — isolated on purpose. +- OSC probe on the controlling `/dev/tty` (TUI already renders there — `tui.rs`) at startup, + raw mode, short timeout: `OSC 4;n;?` (n=0–15) + `OSC 10/11`. Populate slots from real RGB. +- **Synthesize the 6 slots ANSI lacks** (base01/02/04/06/09/0F) per ADR-029's rules. +- **Fallback chain:** per-slot fallback to curated; total failure → curated by bg luminance + (if `OSC 11` answered) else `dark`. **Never hang** — timeout is the backstop. Make `auto` + the default `theme` value. +- Verify: probe parses a synthetic OSC reply into slots; timeout path falls back to curated + (no hang) — drive with a fake tty/reader, do not depend on the test terminal answering. + +### CS7 — View-config settings +- Read `workon.review.outline.width|mode` and `workon.review.diff.layout|zoom` (via CS1), + current hardcoded values as defaults. `outline.width` also addresses M5's deferred + narrow-terminal papercut. +- Verify: each setting overrides its default from a fixture config; unset = current default. + +## Cross-cutting notes / gotchas + +- **Testing:** load `/docs testing` before writing any test; FixtureBuilder + custom + predicates, extend predicates before tests. Pin color off in output-asserting tests + (`NO_COLOR`/`no_color()`) — the user env sets `FORCE_COLOR=3` (see memory). +- **TUI verification:** instrument via log-file + `expect`, never grep ratatui frames (memory). +- **Errors:** any new error types follow ADR-008 (concrete enums, `#[derive(Error, Diagnostic)]`); + load `/docs errors` first. +- **Commit style:** Conventional Commits, single line, scope `review`. No body/footer. +- **Gate before landing each CS:** `cargo test --workspace` + clippy + `-D warnings --all-targets --all-features`, then main-thread diff-read. + +## Deferred (explicitly not this pass) +- `theme = ` / user-supplied base16 scheme files (the "user-configurable colors" tier). + Additive later — the slot *source* is pluggable behind render-time resolution (ADR-029). +- Post-subcommand completion delegation (M6 note), git-inference stack model, ref-range + sources (M5), and all of M7 comments onward. diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index dbee05d..1f6fa82 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -133,6 +133,7 @@ evidence, not to the conclusion. - **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. Design locked 2026-07-06 (plan artifact `iron-lattice`): (1) staging = prototype parity — verbs act only in unstaged/staged panes, combined refuses, direction = pane role (combined-native toggle deferred); (2) cursor-primary nav in all views, scroll derived; (3) full 4-state zoom (`split→combined→unstaged→staged`) with per-file `_gate` downgrade and stacked split panes (per-pane cursor, `w` focus), no collapse debounce; (4) runtime stays sync — poll `IndexSignature` on Tick, synchronous re-diff (no threads/notify dep); (5) queue enqueue+drain same beat, refresh, re-snapshot; (6) footer-swap for refusals/errors + discard confirm; (7) attribution via a new pure `attribute.rs` (membership sets keyed by lnum); (8) line selection in both layouts (inline one-sided, SBS row-pair). — DONE (2026-07-07): shipped as EIGHT changesets `m4-cursor → m4-zoom → m4-attribute → m4-notify → m4-refresh → m4-stage → m4-select → m4-watch` (staging split into hunk/file vs line selection; refresh pulled out as shared infra for stage + watch). Stack-reviewed continuously on the main thread; two real bugs caught by review, not by agent tests: (a) m4-zoom sub-view panes rendered worktree text where index text belonged — fixed with per-role blob sourcing (`read_index_blob`); (b) m4-select applied a multi-hunk line selection as N independent patches, which libgit2 rejects because each per-hunk patch's line numbering assumes the others are present — fixed by merging into ONE `PatchText` (`ops::apply_line_selections`), pinned by a line-shift tripwire test. Acceptance met: staging parity dogfooded against real git (stage/unstage/discard hunk/file/line, partial-hunk selection); index watcher confirmed live (external `git add` auto-refreshes on the next Tick — the watcher polls `.git/index`'s signature, so it catches index writes, not bare worktree edits, matching its name). Runtime stayed sync (no threads); combined-native staging toggle and spike `--dump`/`--bench` modes remain deferred. - **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). Design locked 2026-07-07 (plan artifact `cairn-ledger`, 9 forks): (1) source = per-changeset `ChangesetView`, committed changesets built via `DiffState::from_committed` (empty staged/unstaged sub-models); (2) mode = derived `is_committed` + targeted guards, leaning on the existing `effective_zoom` collapse (empty sub-diffs → combined-only for free); (3) outline = left side pane, all four modes (flat/tree/stack/stack-tree); (4) load = hybrid (eager per-changeset `DiffState`, lazy per-file `FileView`); (5) nav = continuous `]f`/`[f` across the stack + `]c`/`[c` changeset jumps; (6) open-at = honor the lib's `current` flag; (7) source scope = auto-detect Graphite else single uncommitted changeset (M2–M4 preserved, backward-compatible); (8) changeset indicator = new top winbar; (9) needs-restack = first-class glyph + amber color (the lib gives a real boolean, unlike the prototype's title-string suffix). — DONE (2026-07-07): shipped as FOUR changesets `m5-stack-source → m5-changeset-nav → m5-outline-core → m5-outline-tree`, each delegated to an `implementer` subagent and main-thread diff-read before the next landed. The M1 lib already provided `assemble_changesets` + the `diff_changeset` router, so M5 was almost entirely review-App wiring; the uncommitted layer becomes one changeset *inside* the stack, keeping all of M4's staging/zoom/attribution working on it while committed changesets render read-only. Two correctness fixes surfaced during implementation, neither in the plan: (a) a committed changeset's combined-role old side must read its `base` commit's tree, not live `HEAD` (`old_side_tree_for`); (b) skipping attribution for committed changesets is not just a guard — without it `Attribution::build(None, None)`'s empty sets miscolored every Add cell as "already staged" (dim), pinned by a render test. Acceptance met: dogfooded against this repo's own live 33-changeset Graphite stack via a PTY harness (winbar changeset counter, `]c`/`[c` nav, outline flat/stack/tree/stack-tree modes with correct tree guides, open-on-uncommitted-layer focus) — a clean exit, no panic, exercising the real `resolve_changesets`→`assemble_graphite` path the hand-built unit tests don't. Full workspace green (41 suites, 804 tests, 0 fail), clippy `-D warnings --all-targets --all-features` clean. Deferred: Git-inference (`StackModel::Git`) and explicit ref-range review (the broader "ref sources") — auto-detect ships Graphite-or-uncommitted only; a fixed 35-col outline with no narrow-terminal handling. - **M6 — git-workon CLI integration.** Ordered first: dependency-free, lowest-risk, and it unlocks dogfooding every later milestone through the real `git workon review` entry point (not `cargo run`). Cargo-style external-subcommand dispatch — `git-workon`'s unknown subcommand execs `git-workon-` on PATH with args passed through (none exists today; `Cmd` is a closed enum), so `git workon review` works via git's native `git-*` dispatch. Plus completion: the review binary gains `CompleteEnv` (its `Cli` is currently empty) so it is a `COMPLETE=` responder, and git-workon's dynamic completer enumerates `git-workon-*` on PATH and surfaces them as top-level subcommand candidates (so `git workon ` offers `review`). **Post-subcommand sub-delegation** (`git workon review ` → shell out to the review binary's completer) is **deferred, not built**: the review binary's `Cli` is currently empty (zero candidates), and MCP lands as `git workon mcp` (not a review subcommand — see M9), so there is nothing to delegate today. Its real trigger is *not* MCP — it's whenever the review binary gains its source-selector arg (`stack | uncommitted | | | pr-####`, the deferred v1 sources), whose values (refs, ranges, PR numbers) are genuinely completion-worthy. Wire delegation then, against that real surface; the review binary is already a `COMPLETE=` responder, so only the git-workon-side shell-out remains. Acceptance: `git workon review` dispatches with args through; `git workon ` lists external subcommands including `review`. DONE (2026-07-07): shipped as THREE changesets `m6-dispatch → m6-review-complete → m6-complete-enum` — (1) manual pre-parse PATH intercept (`dispatch.rs`), NOT clap `allow_external_subcommands` (which would break the flattened-`find.name` default-command routing); (2) review binary as `COMPLETE=` responder; (3) top-level external enumeration in the completer. Two seam facts surfaced: the clap_complete bash protocol needs `_CLAP_COMPLETE_INDEX` (word position) or it emits "no completion generated", and an empty `Cli` yields zero candidates (which is what made sub-delegation pointless to build). +- **M6.5 — everyday-usability pass (keybindings + theming + view-config).** Inserted ahead of M7 (2026-07-07): comments are deprioritized until the tool is usable for the author's own everyday review work. Keybindings and theming were never milestones — they were baked in as hardcoded values during M3–M5 (a `match` in `tui.rs`, a `const … Color::Rgb` block in `render.rs`). This pass makes both user-configurable and adds discoverability, plus gives previously-hardcoded view settings a config home. Design locked 2026-07-07; two ADRs: [ADR-028](../adr/028-review-git-native-config-schema.md) (git-native config schema — `workon.review.*`, action-as-key per-view keymaps, token grammar) and [ADR-029](../adr/029-review-theming-base16-hybrid.md) (hybrid base16 theming, render-time color resolution, terminal-derived `auto`). Scope: (1) `ReviewConfig` reader — the review binary reads git config for the first time; (2) action registry + configurable per-view keymaps, defaults unchanged; (3) help surface (persistent curated per-view footer + `?` overlay); (4) base16 `Theme` primitive + render-time resolution refactor (`FgSpan` carries capture index); (5) curated dark+light schemes + `theme=dark|light`; (6) `theme=auto` terminal-derivation OSC probe with curated fallback; (7) view-config (`outline.width`/`mode`, `diff.layout`/`zoom`). Full plan: `docs/plans/review-usability-pass.md`. Acceptance: rebind any diff/outline/global action via `git config`; `?` overlay + footer render the resolved map; `theme` selects auto/dark/light with terminal-derived `auto` degrading to curated on probe failure; view defaults honored from config. Comments (M7) resume after. - **M7 — review comments.** On-disk comment store (`.review/`, JSON-or-sqlite; both deps already in the workspace) keyed to changeset/path/side/line, with a **rebase-survival anchoring strategy** — the central greenfield fork (the frozen prototype has *no* comment store, MCP, or editor-jump: all three are designed from scratch; it only hands us the `(changeset_id, path, side, lnum)` location model with `head_ref ∈ {SHA, WORKTREE, INDEX}` and no re-anchoring precedent). Plus TUI comment UX: create a comment on a diff line, view inline/in a pane, mark resolved, store-watch refresh. Acceptance: a human reviews a changeset, leaves comments pinned to lines, and they persist + re-anchor across a diff refresh (manual `r` / Tick). **Comment-store home is a first-class M7 fork, not just its schema:** M9's `git workon mcp` (in the `git-workon` crate) must read comments, making git-workon a *second consumer* of the store — so it cannot live inside the review binary. It belongs in a lib both the review crate and git-workon can depend on (git-workon-lib, or a new shared crate). This reopens the RFC's deferred "no separate core crate until a second consumer exists" decision — resolve it here. - **M8 — edit flow.** Editor-jump from a diff line to the file on disk — embedded `nvim --server $NVIM --remote + `, standalone `$EDITOR + ` (detect via `$NVIM`); file watcher refreshes the diff (and re-anchors comments) on external save — port the prototype's debounced repo-root watcher behavior (`FocusGained` fallback, viewport-preserving refresh, selection clamp; the Neovim mechanism doesn't translate, the behavior does). Ordered right after comments so watch-refresh and comment re-anchoring co-develop and stress-test the M7 anchor model immediately. Acceptance: jump opens the right file+line; saving refreshes the diff without losing viewport or comment anchors. - **M9 — MCP agent loop (`git workon mcp`).** A **first-class `mcp` subcommand of the main `git-workon` binary** (not a review subcommand) starting one stdio MCP server that **bridges both domains**: git-workon-lib worktree tools (`agent-integration.md` Model C — `worktree_create`/`list`/`find`/`remove`/`create_from_pr`) *and* the review comment store (list comments, mark addressed; the TUI reflects changes). One server, one config entry, both capabilities — the unified direction (superseding the earlier "review-comment-only vs unified" fork and the RFC's original `git-workon-review mcp` framing). Deliberately last so the cross-cutting MCP-stack commitment (crate — `rmcp` vs hand-rolled JSON-RPC-over-stdio — transport, error mapping) is made once across both surfaces, and because it depends on the M7 comment store living in a shared lib (see M7). Consequence: `git-workon` gains a dependency on the comment-store lib; the worktree-MCP no longer wants a separate `git-workon-mcp` crate. Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review — plus worktree tools served from the same `git workon mcp`. From 6fe1efe2347d841a4c92107072aa90631f458ed7 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 23:54:49 -0400 Subject: [PATCH 047/203] feat(review): add ReviewConfig git-native config reader --- git-workon-review/src/config.rs | 416 ++++++++++++++++++++++++++++++++ git-workon-review/src/lib.rs | 1 + 2 files changed, 417 insertions(+) create mode 100644 git-workon-review/src/config.rs diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs new file mode 100644 index 0000000..c89b820 --- /dev/null +++ b/git-workon-review/src/config.rs @@ -0,0 +1,416 @@ +//! `ReviewConfig` — git-native config reader for the review TUI. +//! +//! Mirrors `git-workon-lib/src/config.rs`'s `WorkonConfig` pattern: reads via +//! `repo.config()` (git2's layered config: local `.git/config` > global `~/.gitconfig` > +//! system), typed getters over `get_string`/`get_i64`/[`git2::Config::entries`]. See +//! [ADR-006](../../../docs/adr/006-git-native-config.md) for the git-native config decision +//! this extends, and [ADR-028](../../../docs/adr/028-review-git-native-config-schema.md) for +//! the `workon.review.*` schema this reads. +//! +//! ## Status +//! +//! CS1 of the everyday-usability pass (see `docs/plans/review-usability-pass.md`): reader +//! infrastructure + typed getters only. Nothing here is wired into rendering or dispatch yet +//! — that's CS2 (keymaps), CS4/CS5/CS6 (theming), and CS7 (view settings). +//! +//! ## Configuration keys +//! +//! ```gitconfig +//! [workon "review"] +//! theme = dark ; auto | dark | light (default: auto) +//! +//! [workon "review.diff.bind"] +//! stage-hunk = s x ; action = key tokens (space-separated) +//! +//! [workon "review.outline.bind"] +//! open = enter +//! +//! [workon "review.bind"] +//! quit = q esc ; bare `review.bind` = global view (active in every view) +//! +//! [workon "review.outline"] +//! width = 32 +//! mode = tree +//! +//! [workon "review.diff"] +//! layout = split +//! zoom = combined +//! ``` + +use git2::Repository; + +/// Which view a keybinding or view-setting applies to. +/// +/// `Global` is the bare `workon.review.bind.` / has no view segment in the config +/// key — active in every view. `Diff` and `Outline` are the per-view namespaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum View { + Global, + Diff, + Outline, +} + +impl View { + /// The config key segment for this view, or `None` for [`View::Global`], which has no + /// segment (`workon.review.bind.`, not `workon.review.global.bind.`). + fn as_key_segment(self) -> Option<&'static str> { + match self { + View::Global => None, + View::Diff => Some("diff"), + View::Outline => Some("outline"), + } + } + + fn parse_segment(segment: &str) -> Option { + match segment { + "diff" => Some(View::Diff), + "outline" => Some(View::Outline), + _ => None, + } + } +} + +/// `workon.review.theme` — see [ADR-029](../../../docs/adr/029-review-theming-base16-hybrid.md). +/// +/// `auto` (terminal-derived) is the spec default; the terminal-derivation probe itself is +/// CS6. Until CS6 lands, callers of [`ReviewConfig::theme`] decide how to treat `Auto`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Theme { + #[default] + Auto, + Dark, + Light, +} + +/// One decomposed `workon.review..bind.` (or bare `workon.review.bind.`) +/// config entry: the raw, unparsed value string. Token-grammar parsing (space/reserved-word/ +/// modifier/chord) is CS2's job — see [ADR-028](../../../docs/adr/028-review-git-native-config-schema.md). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawBinding { + pub view: View, + pub action: String, + /// Space-separated key tokens, unparsed (e.g. `"j down"`, `"]f"`, `""` for an explicit + /// unbind). + pub keys: String, +} + +/// Decompose a fully-qualified config variable name (as returned by +/// [`git2::ConfigEntry::name`]) into its (view, action) components, per ADR-028's grammar: +/// bare `workon.review.bind.` is the global keymap; `workon.review..bind.` +/// is a per-view keymap entry. Returns `None` for anything else under `workon.review.*` +/// (`theme`, a view setting, or an unrecognized shape) — not this reader's job to +/// validate/warn on unknown bind shapes; that's CS2's collision/unknown-action validation +/// pass. View settings and `theme` are read directly by their own getters, not through this. +fn parse_bind_key(name: &str) -> Option<(View, String)> { + let rest = name.strip_prefix("workon.review.")?; + let parts: Vec<&str> = rest.split('.').collect(); + match parts.as_slice() { + ["bind", action] => Some((View::Global, (*action).to_string())), + [view, "bind", action] => { + View::parse_segment(view).map(|view| (view, (*action).to_string())) + } + _ => None, + } +} + +/// Configuration reader for `workon.review.*` settings stored in git config. +/// +/// Mirrors `git-workon-lib`'s `WorkonConfig`: opens the repository's layered config (local > +/// global > system) and exposes typed getters. Unlike `WorkonConfig`, there is no CLI-override +/// precedence here — `git-workon-review`'s CLI takes no relevant flags yet. +pub struct ReviewConfig<'repo> { + repo: &'repo Repository, +} + +impl<'repo> ReviewConfig<'repo> { + /// Create a new config reader for the given repository. + pub fn new(repo: &'repo Repository) -> Self { + Self { repo } + } + + /// Get `workon.review.theme`, parsed into a [`Theme`]. Defaults to [`Theme::Auto`] if + /// unset or unrecognized. + pub fn theme(&self) -> Result { + let config = self.repo.config()?; + let theme = match config.get_string("workon.review.theme") { + Ok(raw) => match raw.as_str() { + "dark" => Theme::Dark, + "light" => Theme::Light, + _ => Theme::Auto, + }, + Err(_) => Theme::Auto, + }; + Ok(theme) + } + + /// 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 + /// appear as separate entries — unlike `get_string`, which honors precedence). So we dedup by + /// (view, action) and read the winning value via `get_string`, giving one binding per pair + /// with git's native precedence (local > global > system) applied. + /// + /// Token-grammar parsing (space/reserved-word/modifier/chord), unknown-action validation, + /// and collision detection are CS2's job — this is the raw read only. + pub fn bindings(&self) -> Result, git2::Error> { + let config = self.repo.config()?; + // Gather each (view, action) once with its fully-qualified key name. The `entries()` + // iterator borrows `config`, so collect names first (deduping shadowed layers), then read + // precedence-correct values via `get_string` after the iterator is dropped. + let mut pairs: Vec<(View, String, String)> = 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 let Some((view, action)) = parse_bind_key(name) { + if seen.insert((view, action.clone())) { + pairs.push((view, action, name.to_string())); + } + } + } + } + let mut out = Vec::with_capacity(pairs.len()); + for (view, action, name) in pairs { + let keys = config.get_string(&name)?; + out.push(RawBinding { view, action, keys }); + } + Ok(out) + } + + /// Get `workon.review.outline.width`, raw. `None` if unset — callers apply the current + /// hardcoded default (CS7). + pub fn outline_width(&self) -> Result, git2::Error> { + self.get_view_i64(View::Outline, "width") + } + + /// Get `workon.review.outline.mode`, raw. `None` if unset. + pub fn outline_mode(&self) -> Result, git2::Error> { + self.get_view_string(View::Outline, "mode") + } + + /// Get `workon.review.diff.layout`, raw. `None` if unset. + pub fn diff_layout(&self) -> Result, git2::Error> { + self.get_view_string(View::Diff, "layout") + } + + /// Get `workon.review.diff.zoom`, raw. `None` if unset. + pub fn diff_zoom(&self) -> Result, git2::Error> { + self.get_view_string(View::Diff, "zoom") + } + + /// 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). + fn setting_key(view: View, setting: &str) -> String { + let segment = view + .as_key_segment() + .expect("view settings are only read for Diff/Outline, never Global"); + format!("workon.review.{segment}.{setting}") + } + + fn get_view_string(&self, view: View, setting: &str) -> Result, git2::Error> { + let config = self.repo.config()?; + match config.get_string(&Self::setting_key(view, setting)) { + Ok(val) => Ok(Some(val)), + Err(_) => Ok(None), + } + } + + fn get_view_i64(&self, view: View, setting: &str) -> Result, git2::Error> { + let config = self.repo.config()?; + match config.get_i64(&Self::setting_key(view, setting)) { + Ok(val) => Ok(Some(val)), + Err(_) => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use git_workon_fixture::prelude::*; + + use super::*; + + #[test] + fn theme_defaults_to_auto_when_unset() { + let fixture = FixtureBuilder::new().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let config = ReviewConfig::new(repo); + assert_eq!(config.theme().expect("theme"), Theme::Auto); + } + + #[test] + fn theme_reads_dark_and_light() { + let fixture = FixtureBuilder::new() + .config("workon.review.theme", "dark") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + assert_eq!(ReviewConfig::new(repo).theme().expect("theme"), Theme::Dark); + + let fixture = FixtureBuilder::new() + .config("workon.review.theme", "light") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + assert_eq!( + ReviewConfig::new(repo).theme().expect("theme"), + Theme::Light + ); + } + + #[test] + fn theme_falls_back_to_auto_on_unrecognized_value() { + let fixture = FixtureBuilder::new() + .config("workon.review.theme", "solarized") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + assert_eq!(ReviewConfig::new(repo).theme().expect("theme"), Theme::Auto); + } + + #[test] + fn bindings_is_empty_when_unset() { + let fixture = FixtureBuilder::new().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + assert!(ReviewConfig::new(repo) + .bindings() + .expect("bindings") + .is_empty()); + } + + #[test] + fn bindings_decomposes_view_and_global_keys() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.bind.stage-hunk", "s x") + .config("workon.review.outline.bind.open", "enter") + .config("workon.review.bind.quit", "q esc") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let mut bindings = ReviewConfig::new(repo).bindings().expect("bindings"); + bindings.sort_by(|a, b| a.action.cmp(&b.action)); + + assert_eq!( + bindings, + vec![ + RawBinding { + view: View::Outline, + action: "open".to_string(), + keys: "enter".to_string(), + }, + RawBinding { + view: View::Global, + action: "quit".to_string(), + keys: "q esc".to_string(), + }, + RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: "s x".to_string(), + }, + ] + ); + } + + #[test] + fn bindings_ignores_non_binding_keys() { + let fixture = FixtureBuilder::new() + .config("workon.review.theme", "dark") + .config("workon.review.outline.width", "32") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + assert!(ReviewConfig::new(repo) + .bindings() + .expect("bindings") + .is_empty()); + } + + #[test] + fn bindings_dedups_a_key_set_in_multiple_layers_to_the_winning_value() { + // `git2`'s entries() surfaces a key once per config layer it's set in; bindings() must + // emit ONE RawBinding per (view, action), carrying the precedence-correct (get_string) + // value — not one-per-layer with a shadowed value. Simulate multiple layers with a + // multivar on the local config (two values for one key), which entries() likewise yields + // as two entries and get_string resolves to the last. + let fixture = FixtureBuilder::new().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let cfg_path = repo.path().join("config"); + for v in ["q", "x"] { + let status = std::process::Command::new("git") + .args([ + "config", + "--file", + cfg_path.to_str().expect("config path utf8"), + "--add", + "workon.review.bind.quit", + v, + ]) + .status() + .expect("git config --add"); + assert!(status.success(), "git config --add failed"); + } + + let bindings = ReviewConfig::new(repo).bindings().expect("bindings"); + let quit: Vec<_> = bindings + .iter() + .filter(|b| b.view == View::Global && b.action == "quit") + .collect(); + assert_eq!( + quit.len(), + 1, + "one binding per (view, action), not one per config layer; got {bindings:?}" + ); + assert_eq!( + quit[0].keys, "x", + "get_string resolves the multivar to its last/winning value" + ); + } + + #[test] + fn view_settings_read_when_set() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.width", "40") + .config("workon.review.outline.mode", "tree") + .config("workon.review.diff.layout", "split") + .config("workon.review.diff.zoom", "staged") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let config = ReviewConfig::new(repo); + + assert_eq!(config.outline_width().expect("width"), Some(40)); + assert_eq!( + config.outline_mode().expect("mode"), + Some("tree".to_string()) + ); + assert_eq!( + config.diff_layout().expect("layout"), + Some("split".to_string()) + ); + assert_eq!( + config.diff_zoom().expect("zoom"), + Some("staged".to_string()) + ); + } + + #[test] + fn view_settings_default_to_none_when_unset() { + let fixture = FixtureBuilder::new().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let config = ReviewConfig::new(repo); + + assert_eq!(config.outline_width().expect("width"), None); + assert_eq!(config.outline_mode().expect("mode"), None); + assert_eq!(config.diff_layout().expect("layout"), None); + assert_eq!(config.diff_zoom().expect("zoom"), None); + } +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 0a62bc4..1f78c89 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -17,6 +17,7 @@ pub mod align; pub mod app; pub mod apply; pub mod attribute; +pub mod config; pub mod error; pub mod file_ops; pub mod highlight; From 72253ee03e19db9055d64a89450b4bc50bde38ac Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 00:24:54 -0400 Subject: [PATCH 048/203] feat(review): make keybindings git-config configurable --- git-workon-review/src/keymap.rs | 924 ++++++++++++++++++++++++++++++++ git-workon-review/src/lib.rs | 1 + git-workon-review/src/main.rs | 20 +- git-workon-review/src/tui.rs | 350 +++++++----- 4 files changed, 1147 insertions(+), 148 deletions(-) create mode 100644 git-workon-review/src/keymap.rs diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs new file mode 100644 index 0000000..b1e8fcb --- /dev/null +++ b/git-workon-review/src/keymap.rs @@ -0,0 +1,924 @@ +//! Action registry, token-grammar parser, and per-view keymap resolution for the review TUI. +//! +//! Replaces `tui.rs`'s formerly-hardcoded `map_key` match with a registry-driven, git-config +//! overridable dispatch (see [ADR-028](../../../docs/adr/028-review-git-native-config-schema.md)). +//! +//! The pieces: +//! - [`Command`] — the enumerable set of *rebindable* actions. This is the compatibility surface +//! ADR-028 calls out: each variant maps to a stable git-config action name (`stage-hunk`, +//! `next-file`, …) in a [`View`] namespace, plus a default key-token string and a human +//! description. The static [`REGISTRY`] table is the single source of truth for all three. +//! - [`parse_value`] — the token-grammar parser: a space-separated config value becomes a set of +//! alternative key *sequences* (a chord like `]f` is one two-key sequence; `j down` is two +//! single-key alternatives). Reserved symbolic names win over literals. +//! - [`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`]. +//! +//! **Not handled here** (stays hardcoded in `tui.rs`): the confirm modal (`y`/`n`/`Esc`) and the +//! whole `Esc`-precedence cascade (confirm > outline-unfocus > selection-cancel > quit). Per +//! ADR-028 those are conventional and safety-sensitive; they are never routed through the +//! registry, so `Esc` is not a registry token. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::config::{RawBinding, View}; + +/// One rebindable action. The action *identity* — distinct from `tui.rs`'s `Action`, which is the +/// concrete effect applied to the `App` (and carries runtime data like a half-page scroll delta +/// that depends on the pane height at dispatch time). `tui.rs` converts a resolved [`Command`] +/// into its `Action`. +/// +/// Every variant appears exactly once in [`REGISTRY`], which pins its config name, view, default +/// keys, and description. Renaming a variant's config name is a breaking change to users' git +/// config (ADR-028's compatibility-surface consequence). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Command { + // Global (active in every view). + Quit, + ToggleOutline, + // Diff view. + CursorDown, + CursorUp, + HalfPageDown, + HalfPageUp, + ScrollTop, + ScrollBottom, + ToggleLayout, + CycleZoom, + ToggleSplitFocus, + Refresh, + StageHunk, + StageFile, + DiscardHunk, + DiscardFile, + StartSelection, + NextFile, + PrevFile, + NextHunk, + PrevHunk, + NextChangeset, + PrevChangeset, + // Outline view. + OutlineDown, + OutlineUp, + OutlineConfirm, + OutlineCycleMode, +} + +/// One row of the action registry: a [`Command`] with its stable config identity (`view` + +/// `name`), the default key tokens that reproduce the pre-config hardcoded binding, and a human +/// description (the help overlay in CS3 renders from this). +#[derive(Debug, Clone, Copy)] +pub struct Registered { + pub command: Command, + pub view: View, + pub name: &'static str, + pub default_keys: &'static str, + pub description: &'static str, +} + +/// The action registry — the single source of truth for defaults, config names, and help text. +/// +/// Order is load-bearing in two ways: **global entries come first** so that on a key collision +/// between a global and a per-view action the global wins (preserving `o`/`q` always working), and +/// within a view earlier entries win later ones (the documented deterministic collision rule). The +/// `default_keys` strings reproduce `tui.rs`'s exact pre-ADR-028 bindings — `Esc` is deliberately +/// absent (it stays hardcoded, see the module doc). +pub static REGISTRY: &[Registered] = &[ + // ── Global ─────────────────────────────────────────────────────────────── + Registered { + command: Command::Quit, + view: View::Global, + name: "quit", + default_keys: "q", + description: "Quit the review", + }, + Registered { + command: Command::ToggleOutline, + view: View::Global, + name: "toggle-outline", + default_keys: "o", + description: "Toggle the outline pane / focus", + }, + // ── Diff view ──────────────────────────────────────────────────────────── + Registered { + command: Command::CursorDown, + view: View::Diff, + name: "cursor-down", + default_keys: "j down", + description: "Move cursor down one line", + }, + Registered { + command: Command::CursorUp, + view: View::Diff, + name: "cursor-up", + default_keys: "k up", + description: "Move cursor up one line", + }, + Registered { + command: Command::HalfPageDown, + view: View::Diff, + name: "half-page-down", + default_keys: "ctrl-d", + description: "Scroll down half a page", + }, + Registered { + command: Command::HalfPageUp, + view: View::Diff, + name: "half-page-up", + default_keys: "ctrl-u", + description: "Scroll up half a page", + }, + Registered { + command: Command::ScrollTop, + view: View::Diff, + name: "scroll-top", + default_keys: "g", + description: "Jump to the top", + }, + Registered { + command: Command::ScrollBottom, + view: View::Diff, + name: "scroll-bottom", + default_keys: "G", + description: "Jump to the bottom", + }, + Registered { + command: Command::ToggleLayout, + view: View::Diff, + name: "toggle-layout", + default_keys: "L", + description: "Toggle side-by-side / inline layout", + }, + Registered { + command: Command::CycleZoom, + view: View::Diff, + name: "cycle-zoom", + default_keys: "z", + description: "Cycle the staged/unstaged zoom", + }, + Registered { + command: Command::ToggleSplitFocus, + view: View::Diff, + name: "toggle-split-focus", + default_keys: "w", + description: "Switch focus between split panes", + }, + Registered { + command: Command::Refresh, + view: View::Diff, + name: "refresh", + default_keys: "r", + description: "Refresh the review", + }, + Registered { + command: Command::StageHunk, + view: View::Diff, + name: "stage-hunk", + default_keys: "s", + description: "Stage the hunk (or selection) under the cursor", + }, + Registered { + command: Command::StageFile, + view: View::Diff, + name: "stage-file", + default_keys: "S", + description: "Stage the whole file", + }, + Registered { + command: Command::DiscardHunk, + view: View::Diff, + name: "discard-hunk", + default_keys: "d", + description: "Discard the hunk (or selection) under the cursor", + }, + Registered { + command: Command::DiscardFile, + view: View::Diff, + name: "discard-file", + default_keys: "D", + description: "Discard the whole file", + }, + Registered { + command: Command::StartSelection, + view: View::Diff, + name: "start-selection", + default_keys: "v", + description: "Start a line selection", + }, + Registered { + command: Command::NextFile, + view: View::Diff, + name: "next-file", + default_keys: "tab ]f", + description: "Go to the next file", + }, + Registered { + command: Command::PrevFile, + view: View::Diff, + name: "prev-file", + default_keys: "backtab [f", + description: "Go to the previous file", + }, + Registered { + command: Command::NextHunk, + view: View::Diff, + name: "next-hunk", + default_keys: "]h", + description: "Go to the next hunk", + }, + Registered { + command: Command::PrevHunk, + view: View::Diff, + name: "prev-hunk", + default_keys: "[h", + description: "Go to the previous hunk", + }, + Registered { + command: Command::NextChangeset, + view: View::Diff, + name: "next-changeset", + default_keys: "]c", + description: "Go to the next changeset", + }, + Registered { + command: Command::PrevChangeset, + view: View::Diff, + name: "prev-changeset", + default_keys: "[c", + description: "Go to the previous changeset", + }, + // ── Outline view ───────────────────────────────────────────────────────── + Registered { + command: Command::OutlineDown, + view: View::Outline, + name: "cursor-down", + default_keys: "j down", + description: "Move the outline cursor down", + }, + Registered { + command: Command::OutlineUp, + view: View::Outline, + name: "cursor-up", + default_keys: "k up", + description: "Move the outline cursor up", + }, + Registered { + command: Command::OutlineConfirm, + view: View::Outline, + name: "open", + default_keys: "enter", + description: "Jump to the selected outline entry", + }, + Registered { + command: Command::OutlineCycleMode, + view: View::Outline, + name: "cycle-mode", + default_keys: "i", + description: "Cycle the outline mode", + }, +]; + +/// One matchable key press: a [`KeyCode`] plus whether Ctrl/Alt are required. **Shift is +/// deliberately not tracked** — an uppercase literal (`G`, `S`) already carries the shift in its +/// char, and crossterm is inconsistent about setting the modifier for it (and for `BackTab`), so +/// matching ignores it. Ctrl/Alt are the only load-bearing modifiers in the token grammar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeyPress { + pub code: KeyCode, + pub ctrl: bool, + pub alt: bool, +} + +impl KeyPress { + /// Normalize an incoming crossterm [`KeyEvent`] into a matchable [`KeyPress`], dropping Shift + /// (see the type's doc comment). + pub fn from_event(event: KeyEvent) -> Self { + KeyPress { + code: event.code, + ctrl: event.modifiers.contains(KeyModifiers::CONTROL), + alt: event.modifiers.contains(KeyModifiers::ALT), + } + } +} + +/// A single trigger: one key ([`j`]) or an ordered multi-key chord (`]f` → `]` then `f`). An +/// action may have several alternatives (`j` *or* `down`), each its own [`KeySeq`]. +pub type KeySeq = Vec; + +/// Map a reserved symbolic token name to its [`KeyCode`]. These win over literal interpretation +/// (ADR-028: `space` is always the spacebar, never a chord of `s p a c e`). `None` for anything +/// not a reserved name. +fn reserved_code(name: &str) -> Option { + let code = match name { + "space" => KeyCode::Char(' '), + "tab" => KeyCode::Tab, + "enter" => KeyCode::Enter, + "esc" => KeyCode::Esc, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "backtab" => KeyCode::BackTab, + _ => { + // f1..=f12 + if let Some(n) = name.strip_prefix('f').and_then(|d| d.parse::().ok()) { + if (1..=12).contains(&n) { + return Some(KeyCode::F(n)); + } + } + return None; + } + }; + Some(code) +} + +/// Parse one whitespace-delimited token into a key sequence, per ADR-028's grammar: +/// strip `ctrl-`/`alt-`/`shift-` modifier prefixes, then interpret the remainder as a reserved +/// symbolic name (wins), a single literal char, or — failing both — a multi-char chord (each char +/// one key press). Returns `None` for an empty or all-prefix token (`""`, `ctrl-`). +fn parse_token(token: &str) -> Option { + let mut rest = token; + let mut ctrl = false; + let mut alt = false; + loop { + if let Some(r) = rest.strip_prefix("ctrl-") { + ctrl = true; + rest = r; + } else if let Some(r) = rest.strip_prefix("alt-") { + alt = true; + rest = r; + } else if let Some(r) = rest.strip_prefix("shift-") { + // Shift is not tracked in matching (see [`KeyPress`]); accept the prefix so a user's + // `shift-` token parses, but it contributes no modifier bit. + rest = r; + } else { + break; + } + } + + if rest.is_empty() { + return None; + } + + // Reserved word wins over any literal interpretation. + if let Some(code) = reserved_code(rest) { + return Some(vec![KeyPress { code, ctrl, alt }]); + } + + let chars: Vec = rest.chars().collect(); + if chars.len() == 1 { + return Some(vec![KeyPress { + code: KeyCode::Char(chars[0]), + ctrl, + alt, + }]); + } + // A chord: each char becomes one press in the sequence. A modifier prefix (rare on a chord) + // applies to every press. + Some( + chars + .into_iter() + .map(|c| KeyPress { + code: KeyCode::Char(c), + ctrl, + alt, + }) + .collect(), + ) +} + +/// Parse a full config value (space-separated tokens) into its alternative key sequences. An empty +/// or whitespace-only value yields no alternatives — an explicit *unbind* (ADR-028). Unparseable +/// tokens are skipped. +pub fn parse_value(value: &str) -> Vec { + value.split_whitespace().filter_map(parse_token).collect() +} + +/// Render a key sequence back to a human-readable token (for warnings and the help overlay). +pub fn render_seq(seq: &[KeyPress]) -> String { + seq.iter().map(render_press).collect() +} + +fn render_press(press: &KeyPress) -> String { + let mut out = String::new(); + if press.ctrl { + out.push_str("ctrl-"); + } + if press.alt { + out.push_str("alt-"); + } + let name = match press.code { + KeyCode::Char(' ') => "space".to_string(), + KeyCode::Char(c) => c.to_string(), + KeyCode::Tab => "tab".to_string(), + KeyCode::Enter => "enter".to_string(), + KeyCode::Esc => "esc".to_string(), + KeyCode::Up => "up".to_string(), + KeyCode::Down => "down".to_string(), + KeyCode::Left => "left".to_string(), + KeyCode::Right => "right".to_string(), + KeyCode::Home => "home".to_string(), + KeyCode::End => "end".to_string(), + KeyCode::PageUp => "pageup".to_string(), + KeyCode::PageDown => "pagedown".to_string(), + KeyCode::Backspace => "backspace".to_string(), + KeyCode::Delete => "delete".to_string(), + KeyCode::BackTab => "backtab".to_string(), + KeyCode::F(n) => format!("f{n}"), + other => format!("{other:?}"), + }; + out.push_str(&name); + out +} + +/// The outcome of feeding one key to the keymap (see [`Keymap::advance`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Dispatch { + /// The keys so far are a strict prefix of a bound sequence — buffer retained, await more. + Pending, + /// A bound sequence matched exactly — buffer cleared. + Command(Command), + /// Nothing matched — buffer cleared. `mid_sequence` is `true` when this ended a partial chord + /// (so the caller does NOT re-process the key, matching the old bracket-drop behavior); `false` + /// for a fresh single key that matched nothing (where the caller may apply a hardcoded + /// fallback, e.g. `Esc`). + Unmatched { mid_sequence: bool }, +} + +enum MatchResult { + Pending, + Fire(Command), + NoMatch, +} + +/// A resolved, per-view keymap: the registry defaults with a repo's git-config overrides applied, +/// inverted into sequence→command lookup lists for the diff and outline contexts (each includes +/// the always-active global bindings). +pub struct Keymap { + /// Active bindings when the diff has focus: global ∪ diff, in registry order (global first, so + /// a global binding wins a collision). Scanned by [`Self::match_keys`]. + diff: Vec<(KeySeq, Command)>, + /// Active bindings when the outline has focus: global ∪ outline. + outline: Vec<(KeySeq, Command)>, + /// Resolved key sequences per registry row (parallel to [`REGISTRY`]) — the source for the + /// help overlay's "current keys for this action" (CS3). + resolved: Vec>, + /// Config problems collected during resolution (unknown action names, key collisions) — the + /// caller surfaces these through the footer-notice mechanism at startup. + warnings: Vec, +} + +impl Keymap { + /// The registry defaults with no config applied — the pre-ADR-028 hardcoded keymap. Used by + /// the binary when config reading is unavailable, and by dispatch tests. + pub fn defaults() -> Self { + Self::from_bindings(&[]) + } + + /// Resolve the registry defaults against `bindings` (from [`crate::config::ReviewConfig::bindings`]). + /// Each [`RawBinding`] replaces its action's default keys (empty value = unbind); an unknown + /// action name is collected as a warning rather than panicking. Then invert into the per-view + /// lookup lists, warning on (and deterministically resolving) any same-context key collision. + pub fn from_bindings(bindings: &[RawBinding]) -> Self { + let mut warnings = Vec::new(); + + // 1. Seed each registry row with its parsed default keys. + let mut resolved: Vec> = REGISTRY + .iter() + .map(|entry| parse_value(entry.default_keys)) + .collect(); + + // 2. Apply git-config overrides. `bindings` already carries git's native single-value + // precedence per (view, action), so each just replaces that row. + for rb in bindings { + match REGISTRY + .iter() + .position(|entry| entry.view == rb.view && entry.name == rb.action) + { + Some(idx) => resolved[idx] = parse_value(&rb.keys), + None => warnings.push(format!( + "unknown review keybinding action '{}' in {} view (ignored)", + rb.action, + view_label(rb.view) + )), + } + } + + // 3. Invert into the two context lookup lists, detecting collisions. + let diff = build_context(&resolved, View::Diff, &mut warnings); + let outline = build_context(&resolved, View::Outline, &mut warnings); + + warnings.dedup(); + + Self { + diff, + outline, + resolved, + warnings, + } + } + + /// Config problems found during resolution (empty for a clean/default config). + pub fn warnings(&self) -> &[String] { + &self.warnings + } + + /// The resolved key sequences currently bound to `command` (for the help overlay). Empty when + /// the action is unbound. + pub fn keys_for(&self, command: Command) -> &[KeySeq] { + REGISTRY + .iter() + .position(|entry| entry.command == command) + .map(|idx| self.resolved[idx].as_slice()) + .unwrap_or(&[]) + } + + /// Feed one key press, advancing `buffer` (the in-flight sequence) and reporting the outcome. + /// Owns all buffer bookkeeping: retained on [`Dispatch::Pending`], cleared otherwise. + pub fn advance( + &self, + outline_focused: bool, + buffer: &mut Vec, + key: KeyEvent, + ) -> Dispatch { + buffer.push(KeyPress::from_event(key)); + match self.match_keys(outline_focused, buffer) { + MatchResult::Pending => Dispatch::Pending, + MatchResult::Fire(command) => { + buffer.clear(); + Dispatch::Command(command) + } + MatchResult::NoMatch => { + let mid_sequence = buffer.len() > 1; + buffer.clear(); + Dispatch::Unmatched { mid_sequence } + } + } + } + + /// Match the current `buffer` against the active context's bindings. A strict-prefix match + /// takes precedence over an exact one (so a multi-key sequence is never cut short by a shorter + /// binding — the defaults never have both for the same buffer anyway). + fn match_keys(&self, outline_focused: bool, buffer: &[KeyPress]) -> MatchResult { + let list = if outline_focused { + &self.outline + } else { + &self.diff + }; + let mut exact: Option = None; + let mut has_prefix = false; + for (seq, command) in list { + if seq.len() > buffer.len() && seq[..buffer.len()] == *buffer { + has_prefix = true; + } else if seq.as_slice() == buffer { + exact.get_or_insert(*command); + } + } + if has_prefix { + MatchResult::Pending + } else if let Some(command) = exact { + MatchResult::Fire(command) + } else { + MatchResult::NoMatch + } + } +} + +/// 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. +fn build_context( + resolved: &[Vec], + view: View, + warnings: &mut Vec, +) -> Vec<(KeySeq, Command)> { + let mut out: Vec<(KeySeq, Command)> = Vec::new(); + for (idx, entry) in REGISTRY.iter().enumerate() { + if entry.view != View::Global && entry.view != view { + continue; + } + for seq in &resolved[idx] { + if let Some((_, winner)) = out.iter().find(|(existing, _)| existing == seq) { + warnings.push(format!( + "key '{}' is bound to both {} and {} in the {} view; {} wins", + render_seq(seq), + command_label(*winner), + command_label(entry.command), + view_label(view), + command_label(*winner), + )); + } else { + out.push((seq.clone(), entry.command)); + } + } + } + out +} + +/// The config action name for a command (for collision warnings) — its registry `name`. +fn command_label(command: Command) -> &'static str { + REGISTRY + .iter() + .find(|entry| entry.command == command) + .map(|entry| entry.name) + .unwrap_or("?") +} + +fn view_label(view: View) -> &'static str { + match view { + View::Global => "global", + View::Diff => "diff", + View::Outline => "outline", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn press(c: char) -> KeyPress { + KeyPress { + code: KeyCode::Char(c), + ctrl: false, + alt: false, + } + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + /// Feed a whole sequence of key events, returning the terminal [`Dispatch`]. + fn feed(km: &Keymap, outline_focused: bool, events: &[KeyEvent]) -> Dispatch { + let mut buffer = Vec::new(); + let mut last = Dispatch::Unmatched { + mid_sequence: false, + }; + for &ev in events { + last = km.advance(outline_focused, &mut buffer, ev); + } + last + } + + // ── Parser ─────────────────────────────────────────────────────────────── + + #[test] + fn parses_a_literal_single_char() { + assert_eq!(parse_value("s"), vec![vec![press('s')]]); + } + + #[test] + fn parses_a_reserved_word() { + assert_eq!( + parse_value("enter"), + vec![vec![KeyPress { + code: KeyCode::Enter, + ctrl: false, + alt: false, + }]] + ); + } + + #[test] + fn space_reserved_word_wins_over_a_chord_of_its_letters() { + // "space" must be the spacebar, not a five-key chord of s, p, a, c, e. + assert_eq!( + parse_value("space"), + vec![vec![KeyPress { + code: KeyCode::Char(' '), + ctrl: false, + alt: false, + }]] + ); + } + + #[test] + fn parses_a_ctrl_modifier() { + assert_eq!( + parse_value("ctrl-d"), + vec![vec![KeyPress { + code: KeyCode::Char('d'), + ctrl: true, + alt: false, + }]] + ); + } + + #[test] + fn parses_a_two_key_chord() { + assert_eq!(parse_value("]f"), vec![vec![press(']'), press('f')]]); + } + + #[test] + fn parses_multiple_alternatives() { + assert_eq!( + parse_value("j down"), + vec![ + vec![press('j')], + vec![KeyPress { + code: KeyCode::Down, + ctrl: false, + alt: false, + }], + ] + ); + } + + #[test] + fn empty_value_is_an_unbind() { + assert!(parse_value("").is_empty()); + assert!(parse_value(" ").is_empty()); + } + + #[test] + fn f_keys_and_arrows_parse() { + assert_eq!( + parse_value("f5"), + vec![vec![KeyPress { + code: KeyCode::F(5), + ctrl: false, + alt: false, + }]] + ); + } + + // ── Resolution ───────────────────────────────────────────────────────────── + + #[test] + fn defaults_reproduce_the_hardcoded_bindings() { + let km = Keymap::defaults(); + assert!(km.warnings().is_empty(), "defaults never collide"); + // A representative spread across token classes. + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('s'))]), + Dispatch::Command(Command::StageHunk) + ); + assert_eq!( + feed( + &km, + false, + &[key(KeyCode::Char(']')), key(KeyCode::Char('f'))] + ), + Dispatch::Command(Command::NextFile) + ); + assert_eq!( + feed( + &km, + false, + &[KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)] + ), + Dispatch::Command(Command::HalfPageDown) + ); + assert_eq!( + feed(&km, false, &[key(KeyCode::Tab)]), + Dispatch::Command(Command::NextFile) + ); + // Global works from the outline context too. + assert_eq!( + feed(&km, true, &[key(KeyCode::Char('o'))]), + Dispatch::Command(Command::ToggleOutline) + ); + assert_eq!( + feed(&km, true, &[key(KeyCode::Char('j'))]), + Dispatch::Command(Command::OutlineDown) + ); + } + + #[test] + fn a_config_rebind_overrides_the_default() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: "x".to_string(), + }]); + assert!(km.warnings().is_empty()); + // The new key fires the action… + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('x'))]), + Dispatch::Command(Command::StageHunk) + ); + // …and the old default no longer does (it's now unbound). + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('s'))]), + Dispatch::Unmatched { + mid_sequence: false + } + ); + } + + #[test] + fn an_empty_value_unbinds_the_action() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: String::new(), + }]); + assert!(km.keys_for(Command::StageHunk).is_empty()); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('s'))]), + Dispatch::Unmatched { + mid_sequence: false + } + ); + } + + #[test] + fn an_unknown_action_warns_without_panicking() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "frobnicate".to_string(), + keys: "x".to_string(), + }]); + assert_eq!(km.warnings().len(), 1); + assert!(km.warnings()[0].contains("frobnicate")); + } + + #[test] + fn a_collision_warns_and_resolves_deterministically() { + // Rebind stage-hunk onto `j`, which already means cursor-down in the diff view. + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: "j".to_string(), + }]); + assert_eq!(km.warnings().len(), 1); + assert!(km.warnings()[0].contains("cursor-down")); + assert!(km.warnings()[0].contains("stage-hunk")); + // Registry order: cursor-down is declared before stage-hunk, so it wins. + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('j'))]), + Dispatch::Command(Command::CursorDown) + ); + } + + #[test] + fn a_global_binding_wins_a_collision_with_a_view_binding() { + // Rebind diff's refresh onto `o`, the global toggle-outline key. + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "refresh".to_string(), + keys: "o".to_string(), + }]); + assert_eq!(km.warnings().len(), 1); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('o'))]), + Dispatch::Command(Command::ToggleOutline) + ); + } + + // ── Dispatch / sequences ────────────────────────────────────────────────── + + #[test] + fn a_partial_chord_reports_pending_then_fires() { + let km = Keymap::defaults(); + let mut buffer = Vec::new(); + assert_eq!( + km.advance(false, &mut buffer, key(KeyCode::Char(']'))), + Dispatch::Pending + ); + assert_eq!(buffer.len(), 1, "the prefix key is retained"); + assert_eq!( + km.advance(false, &mut buffer, key(KeyCode::Char('h'))), + Dispatch::Command(Command::NextHunk) + ); + assert!(buffer.is_empty(), "the buffer clears once the chord fires"); + } + + #[test] + fn an_unrecognized_chord_suffix_drops_the_buffer() { + let km = Keymap::defaults(); + let mut buffer = Vec::new(); + km.advance(false, &mut buffer, key(KeyCode::Char(']'))); + assert_eq!( + km.advance(false, &mut buffer, key(KeyCode::Char('x'))), + Dispatch::Unmatched { mid_sequence: true } + ); + assert!(buffer.is_empty()); + } + + #[test] + fn a_rebound_chord_still_works() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: "gs".to_string(), + }]); + assert_eq!( + feed( + &km, + false, + &[key(KeyCode::Char('g')), key(KeyCode::Char('s'))] + ), + Dispatch::Command(Command::StageHunk) + ); + } +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 1f78c89..bf6e995 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -21,6 +21,7 @@ pub mod config; pub mod error; pub mod file_ops; pub mod highlight; +pub mod keymap; pub mod model; pub mod ops; pub mod outline; diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 49607ce..39e454a 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -5,7 +5,9 @@ use clap_complete::env::CompleteEnv; use git2::Repository; use miette::{IntoDiagnostic, Result}; use workon_review::acquire::{diff_changeset, resolve_changesets}; -use workon_review::app::{App, ChangesetView}; +use workon_review::app::{App, ChangesetView, Severity}; +use workon_review::config::ReviewConfig; +use workon_review::keymap::Keymap; /// A TUI for reviewing changesets #[derive(Debug, Parser)] @@ -44,13 +46,27 @@ fn main() -> Result<()> { return Ok(()); } + // Resolve the keymap from git config once at startup, BEFORE `repo` moves into `App` + // (ADR-028). 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(), + }; + // `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 = App::from_changesets(repo, views); app.open_current(); - tui::run(&mut app).into_diagnostic()?; + // A misconfigured keybinding 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. + if !keymap.warnings().is_empty() { + app.notify(keymap.warnings().join("; "), Severity::Error); + } + + tui::run(&mut app, &keymap).into_diagnostic()?; Ok(()) } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 212d5c2..0d01d53 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -14,7 +14,7 @@ use std::fs::File; use std::io::{self, Write}; use std::time::Duration; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -22,6 +22,7 @@ use crossterm::terminal::{ use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use workon_review::app::App; +use workon_review::keymap::{Command, Dispatch, KeyPress, Keymap}; use workon_review::render; /// One event the review loop reacts to. `Tick` is now also the index-watcher's poll beat (see the @@ -81,84 +82,80 @@ enum Action { None, } -/// Map one key press to an [`Action`], given `pending` (a `]` or `[` seen on the previous call, -/// awaiting its `f`/`h` suffix), the current pane height (for `Ctrl-d`/`Ctrl-u` half-page -/// deltas), and whether the outline pane currently has focus. Unrecognized suffixes drop the -/// pending bracket rather than re-processing the key. +/// Convert a resolved rebindable [`Command`] into the concrete [`Action`] the loop applies, +/// supplying the runtime context the registry can't hold — here, the pane height that sizes a +/// half-page scroll (`Ctrl-d`/`Ctrl-u`). This is the seam between the config-driven keymap and the +/// hardcoded action effects. +fn command_to_action(command: Command, pane_height: usize) -> Action { + let half_page = (pane_height / 2).max(1) as i64; + match command { + Command::Quit => Action::Quit, + Command::ToggleOutline => Action::ToggleOutline, + Command::CursorDown => Action::MoveCursorBy(1), + Command::CursorUp => Action::MoveCursorBy(-1), + Command::HalfPageDown => Action::MoveCursorBy(half_page), + Command::HalfPageUp => Action::MoveCursorBy(-half_page), + Command::ScrollTop => Action::ScrollTop, + Command::ScrollBottom => Action::ScrollBottom, + Command::ToggleLayout => Action::ToggleLayout, + Command::CycleZoom => Action::CycleZoom, + Command::ToggleSplitFocus => Action::ToggleSplitFocus, + Command::Refresh => Action::Refresh, + Command::StageHunk => Action::StageHunk, + Command::StageFile => Action::StageFile, + Command::DiscardHunk => Action::DiscardHunk, + Command::DiscardFile => Action::DiscardFile, + Command::StartSelection => Action::StartSelection, + Command::NextFile => Action::NextFile, + Command::PrevFile => Action::PrevFile, + Command::NextHunk => Action::NextHunk, + Command::PrevHunk => Action::PrevHunk, + Command::NextChangeset => Action::NextChangeset, + Command::PrevChangeset => Action::PrevChangeset, + Command::OutlineDown => Action::OutlineMoveBy(1), + Command::OutlineUp => Action::OutlineMoveBy(-1), + Command::OutlineConfirm => Action::OutlineConfirm, + Command::OutlineCycleMode => Action::OutlineCycleMode, + } +} + +/// Map one key press to an [`Action`] through the resolved [`Keymap`], given `pending` (the +/// in-flight multi-key sequence buffer — generalized from the old `]`/`[` bracket chord to ANY +/// bound sequence), the current pane height (for the half-page deltas), and whether the outline +/// pane currently has focus. /// -/// `outline_focused` re-routes the plain single-key map (NOT the bracket-pending path, which is -/// diff-only chording that can't be mid-flight while the outline has focus) to the outline's own -/// small key set (locked design: "only outline-relevant keys — `j k Enter i o Esc` — act" while -/// it has focus). `o` always toggles regardless of focus (checked before the split) since it's -/// the one key that must work from EITHER side to move focus between panes; `q` still quits from -/// either side too — the locked design only enumerates outline-focused keys, it doesn't say `q` -/// should stop working. +/// Dispatch order: +/// 1. The keymap ([`Keymap::advance`]) consumes the key. A bound sequence fires its command; a +/// strict prefix reports [`Dispatch::Pending`] and holds the buffer for the next key; an +/// unrecognized suffix mid-sequence drops the buffer without re-processing (the old +/// bracket-drop behavior, now general). +/// 2. `Esc` stays HARDCODED (ADR-028: the whole `Esc`-precedence cascade is never routed through +/// the registry). Reached only as a fresh, otherwise-unbound key: it unfocuses the outline when +/// the outline has focus, else quits — the terminal leaf of the cascade `update` enforces. +/// +/// `outline_focused` selects the keymap's outline vs diff context; the global bindings (`q`/`o`) +/// are active in both, so `o` toggles and `q` quits from either pane. fn map_key( - pending: &mut Option, + keymap: &Keymap, + pending: &mut Vec, key: KeyEvent, pane_height: usize, outline_focused: bool, ) -> Action { - if let Some(bracket) = pending.take() { - return match (bracket, key.code) { - (']', KeyCode::Char('f')) => Action::NextFile, - ('[', KeyCode::Char('f')) => Action::PrevFile, - (']', KeyCode::Char('h')) => Action::NextHunk, - ('[', KeyCode::Char('h')) => Action::PrevHunk, - (']', KeyCode::Char('c')) => Action::NextChangeset, - ('[', KeyCode::Char('c')) => Action::PrevChangeset, - _ => Action::None, - }; - } - - if key.code == KeyCode::Char('o') { - return Action::ToggleOutline; - } - - if outline_focused { - return match key.code { - KeyCode::Char('q') => Action::Quit, - KeyCode::Char('j') | KeyCode::Down => Action::OutlineMoveBy(1), - KeyCode::Char('k') | KeyCode::Up => Action::OutlineMoveBy(-1), - KeyCode::Enter => Action::OutlineConfirm, - KeyCode::Char('i') => Action::OutlineCycleMode, - KeyCode::Esc => Action::OutlineUnfocus, - _ => Action::None, - }; - } - - match key.code { - KeyCode::Char('q') | KeyCode::Esc => Action::Quit, - KeyCode::Char('j') | KeyCode::Down => Action::MoveCursorBy(1), - KeyCode::Char('k') | KeyCode::Up => Action::MoveCursorBy(-1), - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { - Action::MoveCursorBy((pane_height / 2).max(1) as i64) - } - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - Action::MoveCursorBy(-((pane_height / 2).max(1) as i64)) - } - KeyCode::Char('g') => Action::ScrollTop, - KeyCode::Char('G') => Action::ScrollBottom, - KeyCode::Char('L') => Action::ToggleLayout, - KeyCode::Char('z') => Action::CycleZoom, - KeyCode::Char('w') => Action::ToggleSplitFocus, - KeyCode::Char('r') => Action::Refresh, - KeyCode::Char('s') => Action::StageHunk, - KeyCode::Char('S') => Action::StageFile, - KeyCode::Char('d') => Action::DiscardHunk, - KeyCode::Char('D') => Action::DiscardFile, - KeyCode::Char('v') => Action::StartSelection, - KeyCode::Tab => Action::NextFile, - KeyCode::BackTab => Action::PrevFile, - KeyCode::Char(']') => { - *pending = Some(']'); - Action::None - } - KeyCode::Char('[') => { - *pending = Some('['); - Action::None + match keymap.advance(outline_focused, pending, key) { + Dispatch::Command(command) => command_to_action(command, pane_height), + Dispatch::Pending => Action::None, + Dispatch::Unmatched { mid_sequence } => { + if !mid_sequence && key.code == KeyCode::Esc { + if outline_focused { + Action::OutlineUnfocus + } else { + Action::Quit + } + } else { + Action::None + } } - _ => Action::None, } } @@ -220,7 +217,7 @@ fn apply_action(app: &mut App, action: Action) -> bool { /// /// A `Key` event clears any showing footer notice before applying its own action (cases 2-4); the /// confirm modal (case 1) deliberately does not. -fn update(app: &mut App, pending: &mut Option, event: AppEvent) -> bool { +fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: AppEvent) -> bool { match event { AppEvent::Key(key) if app.pending_confirm.is_some() => { match key.code { @@ -245,7 +242,7 @@ fn update(app: &mut App, pending: &mut Option, event: AppEvent) -> bool { app.clear_notice(); apply_action( app, - map_key(pending, key, app.pane_height, app.outline_focused()), + map_key(keymap, pending, key, app.pane_height, app.outline_focused()), ) } AppEvent::Tick => { @@ -288,7 +285,7 @@ fn install_panic_hook() { /// Run the review TUI's terminal lifecycle and main loop against `app`. Callers must have /// already loaded the initial file (`app.open_current()`) before calling this. -pub fn run(app: &mut App) -> io::Result<()> { +pub fn run(app: &mut App, keymap: &Keymap) -> io::Result<()> { install_panic_hook(); enable_raw_mode()?; let mut out = terminal_writer(); @@ -296,7 +293,7 @@ pub fn run(app: &mut App) -> io::Result<()> { let backend = CrosstermBackend::new(out); let mut terminal = Terminal::new(backend)?; - let result = event_loop(&mut terminal, app); + let result = event_loop(&mut terminal, app, keymap); disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; @@ -308,8 +305,9 @@ pub fn run(app: &mut App) -> io::Result<()> { fn event_loop( terminal: &mut Terminal>, app: &mut App, + keymap: &Keymap, ) -> io::Result<()> { - let mut pending: Option = None; + let mut pending: Vec = Vec::new(); let mut quit = false; loop { @@ -320,13 +318,15 @@ fn event_loop( } if let Some(event) = next_event(Duration::from_millis(200))? { - quit = update(app, &mut pending, event); + quit = update(app, keymap, &mut pending, event); } } } #[cfg(test)] mod tests { + use crossterm::event::KeyModifiers; + use super::*; fn key(code: KeyCode) -> KeyEvent { @@ -339,163 +339,175 @@ mod tests { #[test] fn quit_keys_map_to_quit() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('q')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('q')), 20, false), Action::Quit ); assert_eq!( - map_key(&mut pending, key(KeyCode::Esc), 20, false), + map_key(&km, &mut pending, key(KeyCode::Esc), 20, false), Action::Quit ); } #[test] fn scroll_keys_map_by_one_line() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('j')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('j')), 20, false), Action::MoveCursorBy(1) ); assert_eq!( - map_key(&mut pending, key(KeyCode::Down), 20, false), + map_key(&km, &mut pending, key(KeyCode::Down), 20, false), Action::MoveCursorBy(1) ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('k')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('k')), 20, false), Action::MoveCursorBy(-1) ); assert_eq!( - map_key(&mut pending, key(KeyCode::Up), 20, false), + map_key(&km, &mut pending, key(KeyCode::Up), 20, false), Action::MoveCursorBy(-1) ); } #[test] fn ctrl_d_u_scroll_by_half_the_pane_height() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, ctrl_key('d'), 21, false), + map_key(&km, &mut pending, ctrl_key('d'), 21, false), Action::MoveCursorBy(10) ); assert_eq!( - map_key(&mut pending, ctrl_key('u'), 21, false), + map_key(&km, &mut pending, ctrl_key('u'), 21, false), Action::MoveCursorBy(-10) ); // A pane height of 1 still scrolls by at least one line. assert_eq!( - map_key(&mut pending, ctrl_key('d'), 1, false), + map_key(&km, &mut pending, ctrl_key('d'), 1, false), Action::MoveCursorBy(1) ); } #[test] fn g_and_shift_g_map_to_top_and_bottom() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('g')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('g')), 20, false), Action::ScrollTop ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('G')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('G')), 20, false), Action::ScrollBottom ); } #[test] fn shift_l_maps_to_toggle_layout() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('L')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('L')), 20, false), Action::ToggleLayout ); } #[test] fn z_and_w_map_to_zoom_and_split_focus() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('z')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('z')), 20, false), Action::CycleZoom ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('w')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('w')), 20, false), Action::ToggleSplitFocus ); } #[test] fn r_maps_to_refresh() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('r')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('r')), 20, false), Action::Refresh ); } #[test] fn tab_and_backtab_map_to_file_nav() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Tab), 20, false), + map_key(&km, &mut pending, key(KeyCode::Tab), 20, false), Action::NextFile ); assert_eq!( - map_key(&mut pending, key(KeyCode::BackTab), 20, false), + map_key(&km, &mut pending, key(KeyCode::BackTab), 20, false), Action::PrevFile ); } #[test] fn bracket_f_maps_to_file_nav() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char(']')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char(']')), 20, false), Action::None ); - assert_eq!(pending, Some(']')); + // The buffer holds the in-flight chord prefix (generalized from the old `Option`). + assert_eq!(pending, vec![KeyPress::from_event(key(KeyCode::Char(']')))]); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('f')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('f')), 20, false), Action::NextFile ); - assert_eq!(pending, None); + assert!(pending.is_empty()); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('[')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('[')), 20, false), Action::None ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('f')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('f')), 20, false), Action::PrevFile ); } #[test] fn bracket_h_maps_to_hunk_nav() { - let mut pending = None; - map_key(&mut pending, key(KeyCode::Char(']')), 20, false); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + map_key(&km, &mut pending, key(KeyCode::Char(']')), 20, false); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('h')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('h')), 20, false), Action::NextHunk ); - map_key(&mut pending, key(KeyCode::Char('[')), 20, false); + map_key(&km, &mut pending, key(KeyCode::Char('[')), 20, false); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('h')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('h')), 20, false), Action::PrevHunk ); } #[test] fn unrecognized_bracket_suffix_drops_pending_without_side_effect() { - let mut pending = None; - map_key(&mut pending, key(KeyCode::Char(']')), 20, false); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + map_key(&km, &mut pending, key(KeyCode::Char(']')), 20, false); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('x')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('x')), 20, false), Action::None ); - assert_eq!( - pending, None, + assert!( + pending.is_empty(), "pending bracket must be cleared, not left dangling" ); } @@ -526,7 +538,8 @@ mod tests { .build() .unwrap(); let mut app = app_from_fixture(&fixture); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); app.notify("something happened", Severity::Info); assert!(app.notice.is_some()); @@ -534,6 +547,7 @@ mod tests { // Any key — even one that maps to no action — dismisses the notice. update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('x'))), ); @@ -554,10 +568,12 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); app.open_current(); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('r'))), ); @@ -578,14 +594,15 @@ mod tests { .build() .unwrap(); let mut app = app_from_fixture(&fixture); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); app.notify("something happened", Severity::Info); - update(&mut app, &mut pending, AppEvent::Tick); + update(&mut app, &km, &mut pending, AppEvent::Tick); assert!(app.notice.is_some(), "a Tick event must not clear a notice"); - update(&mut app, &mut pending, AppEvent::Resize(80, 24)); + update(&mut app, &km, &mut pending, AppEvent::Resize(80, 24)); assert!( app.notice.is_some(), "a Resize event must not clear a notice" @@ -603,13 +620,14 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); app.open_current(); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); // A plain Tick with nothing changed externally must be a safe no-op wired all the way // through `update` — the smoke test for M4's index-watcher hookup (the substantive // signature-change/echo-suppression assertions live in `app.rs`'s own `on_tick` tests, // which have direct access to its private state). - let quit = update(&mut app, &mut pending, AppEvent::Tick); + let quit = update(&mut app, &km, &mut pending, AppEvent::Tick); assert!(!quit, "Tick must never quit the loop"); assert_eq!(app.files().len(), 1); @@ -618,35 +636,37 @@ mod tests { #[test] fn staging_keys_map_to_their_actions() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('s')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('s')), 20, false), Action::StageHunk ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('S')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('S')), 20, false), Action::StageFile ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('d')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('d')), 20, false), Action::DiscardHunk ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('D')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('D')), 20, false), Action::DiscardFile ); // Ctrl-d keeps its half-page meaning — the plain-`d` staging arm must not shadow it. assert_eq!( - map_key(&mut pending, ctrl_key('d'), 20, false), + map_key(&km, &mut pending, ctrl_key('d'), 20, false), Action::MoveCursorBy(10) ); } #[test] fn v_maps_to_start_selection() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('v')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('v')), 20, false), Action::StartSelection ); } @@ -663,18 +683,29 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); app.open_current(); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); // Lowest precedence: with neither a confirm nor a selection up, Esc quits. assert!( - update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Esc))), + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)) + ), "Esc quits when nothing modal is active" ); // Middle precedence: an active selection makes Esc cancel the selection (not quit). app.start_selection(); assert!(app.selection_anchor.is_some()); - let quit = update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Esc))); + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); assert!(!quit, "Esc must not quit while a selection is active"); assert!( app.selection_anchor.is_none(), @@ -684,7 +715,12 @@ mod tests { // Highest precedence: a pending confirm captures Esc as a cancel, even with a selection up. app.start_selection(); app.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); - let quit = update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Esc))); + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); assert!(!quit, "Esc must not quit while a confirm is pending"); assert!( app.pending_confirm.is_none(), @@ -704,7 +740,8 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); app.open_current(); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); // A pending confirm makes every non-answer key a no-op — the cursor doesn't move and the // confirm stays up. @@ -712,6 +749,7 @@ mod tests { let cursor_before = app.cursor; update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('j'))), ); @@ -727,6 +765,7 @@ mod tests { // `n` cancels it. update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('n'))), ); @@ -736,6 +775,7 @@ mod tests { app.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('y'))), ); @@ -808,12 +848,14 @@ mod tests { .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); // Default: open, unfocused. assert!(app.outline_open() && !app.outline_focused()); update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('o'))), ); @@ -821,6 +863,7 @@ mod tests { update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('o'))), ); @@ -831,6 +874,7 @@ mod tests { update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('o'))), ); @@ -855,12 +899,14 @@ mod tests { let diff_cursor_before = app.cursor; let diff_file_before = app.current; let outline_cursor_before = app.outline_cursor(); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); // `k` (not `j`): the outline cursor starts on the last row (cs-b's file, since it's the // active/current changeset), so `j` would clamp in place — `k` has room to move. update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('k'))), ); @@ -891,9 +937,15 @@ mod tests { app.toggle_outline(); // close app.toggle_outline(); // open + focus assert!(app.outline_focused()); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); - let quit = update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Esc))); + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); assert!(!quit, "Esc must not quit while the outline has focus"); assert!( @@ -920,9 +972,15 @@ mod tests { assert!(app.outline_focused()); // Move the outline cursor up onto cs-a's header row. app.outline_move_by(-3); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); - update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Enter))); + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Enter)), + ); assert_eq!( app.current_cs(), From 2b5a1d460211ff29f9124bdee9d9c196ed701283 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 00:41:34 -0400 Subject: [PATCH 049/203] feat(review): add help footer and ? overlay --- git-workon-review/src/app.rs | 24 ++++ git-workon-review/src/keymap.rs | 240 ++++++++++++++++++++++++++++++++ git-workon-review/src/render.rs | 164 +++++++++++++++++++--- git-workon-review/src/tui.rs | 160 +++++++++++++++++++-- 4 files changed, 562 insertions(+), 26 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 0c5f851..9dcd901 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -659,6 +659,10 @@ pub struct App { /// rebuilt-from-scratch — `open`/`focused`/`mode` persist, like [`Self::layout`]/ /// [`Self::zoom`]) by every diff-initiated nav and by [`Self::refresh`]. outline: OutlineState, + /// Whether the `?` help overlay is showing (CS3). While `true`, `tui::update` intercepts + /// every key as a modal (mirroring [`Self::pending_confirm`]'s capture) — see its doc comment + /// for the precedence between the two modals. + pub help_visible: bool, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -791,6 +795,7 @@ impl App { selection_anchor: None, refresh_coordinator, outline, + help_visible: 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 @@ -1435,6 +1440,13 @@ impl App { } } + /// `?`: toggle the help overlay (CS3). A plain flip — the overlay always renders whatever + /// view currently has keyboard focus (see `render::render_help_overlay`), so there is no + /// extra state to reposition here, unlike [`Self::toggle_outline`]'s three-state cycle. + pub fn toggle_help(&mut self) { + self.help_visible = !self.help_visible; + } + /// Return focus to the diff without closing the outline (`Esc` while the outline has focus — /// `tui::update` routes it here instead of quitting, per the locked design's "Esc must still /// not quit when the outline has focus"). @@ -4753,6 +4765,18 @@ mod tests { ); } + #[test] + fn toggle_help_flips_help_visible() { + let mut app = two_committed_changesets_two_and_one_files(); + assert!(!app.help_visible, "help is closed by default"); + + app.toggle_help(); + assert!(app.help_visible, "toggle_help opens it"); + + app.toggle_help(); + assert!(!app.help_visible, "toggle_help closes it again"); + } + #[test] fn outline_cycle_mode_round_trips_all_four_modes() { let mut app = two_committed_changesets_two_and_one_files(); diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index b1e8fcb..2414b32 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -38,6 +38,7 @@ pub enum Command { // Global (active in every view). Quit, ToggleOutline, + ToggleHelp, // Diff view. CursorDown, CursorUp, @@ -102,6 +103,13 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "o", description: "Toggle the outline pane / focus", }, + Registered { + command: Command::ToggleHelp, + view: View::Global, + name: "toggle-help", + default_keys: "?", + description: "Toggle the help overlay", + }, // ── Diff view ──────────────────────────────────────────────────────────── Registered { command: Command::CursorDown, @@ -624,6 +632,137 @@ fn build_context( out } +/// 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)] +pub struct HelpEntry { + pub keys: String, + pub description: &'static str, +} + +/// One titled group of [`HelpEntry`] rows in the help overlay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HelpSection { + pub title: &'static str, + pub entries: Vec, +} + +/// Build the help overlay's content for `focused` (the view with keyboard focus — [`View::Diff`] +/// or [`View::Outline`]; never [`View::Global`]): a "Global" section, then the focused view's own +/// section, each listing only BOUND actions (an action with no resolved keys — user-unbound — is +/// skipped, per CS3). Pure and `Keymap`-driven — the display never hardcodes a key string, so a +/// rebind shows here automatically. +pub fn help_sections(keymap: &Keymap, focused: View) -> Vec { + vec![ + HelpSection { + title: "Global", + entries: entries_for_view(keymap, View::Global), + }, + HelpSection { + title: view_label_title(focused), + entries: entries_for_view(keymap, focused), + }, + ] +} + +fn entries_for_view(keymap: &Keymap, view: View) -> Vec { + REGISTRY + .iter() + .filter(|entry| entry.view == view) + .filter_map(|entry| { + let seqs = keymap.keys_for(entry.command); + if seqs.is_empty() { + return None; + } + let keys = seqs + .iter() + .map(|seq| render_seq(seq)) + .collect::>() + .join(" "); + Some(HelpEntry { + keys, + description: entry.description, + }) + }) + .collect() +} + +fn view_label_title(view: View) -> &'static str { + match view { + View::Global => "Global", + View::Diff => "Diff", + View::Outline => "Outline", + } +} + +/// 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 { + keymap.keys_for(command).first().map(|seq| render_seq(seq)) +} + +/// One curated footer entry: a resolved key label paired with a short verb, or an up/down PAIR +/// collapsed to a single `down/up verb` entry (e.g. `j/k move`) when both resolve. +enum HintItem { + One(Command, &'static str), + Pair(Command, Command, &'static str), +} + +fn render_hint_item(keymap: &Keymap, item: &HintItem) -> Option { + match item { + HintItem::One(command, label) => { + primary_key(keymap, *command).map(|k| format!("{k} {label}")) + } + HintItem::Pair(down, up, label) => { + match (primary_key(keymap, *down), primary_key(keymap, *up)) { + (Some(d), Some(u)) => Some(format!("{d}/{u} {label}")), + (Some(d), None) => Some(format!("{d} {label}")), + (None, Some(u)) => Some(format!("{u} {label}")), + (None, None) => None, + } + } + } +} + +/// The diff view's curated footer hint set (locked design in CS3): nav, stage/discard, outline, +/// help, quit — ~5-7 entries picked to make the tool feel learnable, not an exhaustive list. +const DIFF_HINTS: &[HintItem] = &[ + HintItem::Pair(Command::CursorDown, Command::CursorUp, "move"), + HintItem::One(Command::StageHunk, "stage"), + HintItem::One(Command::DiscardHunk, "discard"), + HintItem::One(Command::ToggleOutline, "outline"), + HintItem::One(Command::ToggleHelp, "help"), + HintItem::One(Command::Quit, "quit"), +]; + +/// The outline view's curated footer hint set (locked design in CS3). +const OUTLINE_HINTS: &[HintItem] = &[ + HintItem::Pair(Command::OutlineDown, Command::OutlineUp, "move"), + HintItem::One(Command::OutlineConfirm, "open"), + HintItem::One(Command::OutlineCycleMode, "mode"), + HintItem::One(Command::ToggleOutline, "outline"), + HintItem::One(Command::ToggleHelp, "help"), + HintItem::One(Command::Quit, "quit"), +]; + +/// Build the persistent, always-visible footer hint string for `focused` ([`View::Diff`] or +/// [`View::Outline`]; never [`View::Global`]) from the resolved `keymap` — never a hardcoded key +/// string, so a rebind shows here too. A notice temporarily replaces this in the footer (the +/// caller's job, see `render::render_footer`); an unbound curated action is simply dropped from +/// the string rather than leaving a stale/wrong key visible. +pub fn footer_hint(keymap: &Keymap, focused: View) -> String { + let items: &[HintItem] = match focused { + View::Diff => DIFF_HINTS, + View::Outline => OUTLINE_HINTS, + View::Global => &[], + }; + items + .iter() + .filter_map(|item| render_hint_item(keymap, item)) + .collect::>() + .join(" \u{b7} ") +} + /// The config action name for a command (for collision warnings) — its registry `name`. fn command_label(command: Command) -> &'static str { REGISTRY @@ -921,4 +1060,105 @@ mod tests { Dispatch::Command(Command::StageHunk) ); } + + // ── CS3: help overlay / footer hint builders ──────────────────────────── + + #[test] + fn help_sections_groups_global_and_the_focused_view_only() { + let km = Keymap::defaults(); + let sections = help_sections(&km, View::Diff); + + assert_eq!(sections.len(), 2); + assert_eq!(sections[0].title, "Global"); + assert_eq!(sections[1].title, "Diff"); + // Outline-only actions never leak into the diff-focused overlay. + assert!(!sections.iter().any(|s| s + .entries + .iter() + .any(|e| e.description.contains("outline cursor")))); + + let outline_sections = help_sections(&km, View::Outline); + assert_eq!(outline_sections[1].title, "Outline"); + } + + #[test] + fn help_sections_skip_an_unbound_action() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: String::new(), + }]); + let sections = help_sections(&km, View::Diff); + let diff = §ions[1]; + assert!( + !diff + .entries + .iter() + .any(|e| e.description.contains("Stage the hunk")), + "an unbound action must not appear in the help overlay" + ); + } + + #[test] + fn help_sections_render_a_rebound_key_not_the_default() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: "x".to_string(), + }]); + let sections = help_sections(&km, View::Diff); + let diff = §ions[1]; + let stage_row = diff + .entries + .iter() + .find(|e| e.description.contains("Stage the hunk")) + .expect("stage-hunk row present"); + assert_eq!(stage_row.keys, "x", "the overlay must show the REBOUND key"); + } + + #[test] + fn footer_hint_renders_the_curated_diff_entries() { + let km = Keymap::defaults(); + let hint = footer_hint(&km, View::Diff); + assert!(hint.contains("j/k move"), "got: {hint:?}"); + assert!(hint.contains("s stage"), "got: {hint:?}"); + assert!(hint.contains("d discard"), "got: {hint:?}"); + assert!(hint.contains("o outline"), "got: {hint:?}"); + assert!(hint.contains("? help"), "got: {hint:?}"); + assert!(hint.contains("q quit"), "got: {hint:?}"); + } + + #[test] + fn footer_hint_renders_the_curated_outline_entries() { + let km = Keymap::defaults(); + let hint = footer_hint(&km, View::Outline); + assert!(hint.contains("j/k move"), "got: {hint:?}"); + assert!(hint.contains("enter open"), "got: {hint:?}"); + assert!(hint.contains("i mode"), "got: {hint:?}"); + } + + #[test] + fn footer_hint_renders_a_rebound_key_not_the_default() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: "x".to_string(), + }]); + let hint = footer_hint(&km, View::Diff); + assert!(hint.contains("x stage"), "got: {hint:?}"); + assert!(!hint.contains("s stage"), "got: {hint:?}"); + } + + #[test] + fn footer_hint_drops_an_unbound_curated_action_rather_than_a_stale_key() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: String::new(), + }]); + let hint = footer_hint(&km, View::Diff); + assert!(!hint.contains("stage"), "got: {hint:?}"); + // The rest of the curated set is unaffected. + assert!(hint.contains("d discard"), "got: {hint:?}"); + } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index d0087d9..19e36a0 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -9,13 +9,15 @@ use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span as TSpan}; -use ratatui::widgets::Paragraph; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; use crate::align::{CellKind, DisplayRow, InlineRow, Row}; use crate::app::{App, EffectiveZoom, FileView, Layout as AppLayout, Notice, Role, Severity}; use crate::attribute::Attribution; +use crate::config::View; use crate::highlight::FgSpan; +use crate::keymap::{footer_hint, help_sections, Keymap}; use crate::model::FileStatus; use crate::outline::OutlineItem; use crate::wordiff::Span as WordSpan; @@ -341,8 +343,11 @@ fn build_pane_line( } } -/// Render one frame: header, SBS body, footer. -pub fn render(frame: &mut Frame, app: &mut App) { +/// 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`]/ +/// [`crate::keymap::help_sections`]), never a hardcoded key string. +pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap) { let area = frame.area(); let vlayout = Layout::default() .direction(Direction::Vertical) @@ -358,7 +363,7 @@ pub fn render(frame: &mut Frame, app: &mut App) { let footer_area = vlayout[2]; render_header(frame, app, header_area); - render_footer(frame, app, footer_area); + render_footer(frame, app, footer_area, keymap); if app.outline_open() { let hlayout = Layout::default() @@ -383,6 +388,68 @@ pub fn render(frame: &mut Frame, app: &mut App) { // Closed: the diff takes the full body width — the exact M4 look (locked design). render_body(frame, app, body_area); } + + if app.help_visible { + render_help_overlay(frame, app, keymap, area); + } +} + +/// Compute a centered `percent_x` × `percent_y` sub-rect of `area` — the standard ratatui popup +/// pattern (two nested percentage splits). +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] +} + +/// The `?` help overlay (CS3): a centered, bordered modal listing the focused view's + global +/// bindings, from the resolved `keymap` (never hardcoded — see [`crate::keymap::help_sections`]). +/// Focused view = outline when the outline pane has focus, else diff. [`Clear`] wipes the popup +/// area first so the diff content underneath doesn't show through the gaps between glyphs. +fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect) { + let focused = if app.outline_focused() { + View::Outline + } else { + View::Diff + }; + let sections = help_sections(keymap, focused); + + let mut lines: Vec = Vec::new(); + for section in §ions { + if !lines.is_empty() { + lines.push(Line::from("")); + } + lines.push(Line::from(TSpan::styled( + section.title, + Style::default().add_modifier(Modifier::BOLD), + ))); + for entry in §ion.entries { + lines.push(Line::from(format!( + " {:<10} {}", + entry.keys, entry.description + ))); + } + } + + let popup_area = centered_rect(60, 60, area); + frame.render_widget(Clear, popup_area); + let block = Block::default() + .borders(Borders::ALL) + .title(" Help (?/q/Esc to close) "); + 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) @@ -570,8 +637,9 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect) { } /// Footer priority: a pending discard confirm's prompt (warn-toned) wins over a transient notice, -/// which wins over the dim hint line. -fn render_footer(frame: &mut Frame, app: &App, area: Rect) { +/// which wins over the curated hint line (CS3) — a notice TEMPORARILY REPLACES the hint rather +/// than adding a second row; it clears on the user's next keypress (`tui::update`). +fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap) { if let Some(confirm) = &app.pending_confirm { frame.render_widget( Paragraph::new(confirm.prompt.as_str()).style(Style::default().fg(FG_ERROR)), @@ -592,17 +660,15 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { } None => { // While the outline has focus, only outline-relevant keys act (locked design) — the - // diff-editing hint would be actively misleading, so show the outline's own hint - // instead. - let text = if app.outline_focused() { - "j/k move Enter jump i mode o unfocus Esc unfocus q quit" - } else if app.is_committed() { - // A committed changeset is locked to the combined view (locked decision #2) — `z` - // zoom and `w` split-focus have nothing to act on, so drop them from the hint. - "j/k scroll v select s/S stage d/D discard q quit" + // diff-editing hint would be actively misleading, so show the outline's own curated + // hint instead. Built from the resolved `keymap`, never a hardcoded key string, so a + // rebind shows here too (see [`crate::keymap::footer_hint`]). + let focused = if app.outline_focused() { + View::Outline } else { - "j/k scroll v select s/S stage d/D discard z zoom w focus q quit" + View::Diff }; + let text = footer_hint(keymap, focused); frame.render_widget( Paragraph::new(text).style(Style::default().fg(FG_DIM)), area, @@ -1098,11 +1164,16 @@ mod tests { use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; use crate::app::App; + use crate::keymap::Keymap; + /// Render one frame against the default (unrebound) keymap — the vast majority of `render.rs` + /// tests don't care about keybindings at all. Tests that DO (the footer/overlay content tests) + /// build their own [`Keymap`] and call [`render`] directly instead. fn render_once(app: &mut App, width: u16, height: u16) -> Buffer { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|f| render(f, app)).unwrap(); + let keymap = Keymap::defaults(); + terminal.draw(|f| render(f, app, &keymap)).unwrap(); terminal.backend().buffer().clone() } @@ -1721,8 +1792,65 @@ mod tests { .map(|x| cell_text(&buf, x, footer_y)) .collect(); assert!( - footer.contains("j/k scroll"), - "expected the hint string in the footer, got: {footer:?}" + footer.contains("j/k move") && footer.contains("? help"), + "expected the curated diff hint string in the footer, got: {footer:?}" + ); + } + + #[test] + fn footer_shows_the_outline_hint_when_the_outline_has_focus() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + // A lone uncommitted changeset never auto-opens the outline (M4 default) — force it open + // + focused so `render_footer` takes the outline-focused branch. + app.toggle_outline(); + assert!(app.outline_focused()); + + let buf = render_once(&mut app, 80, 10); + let footer_y = buf.area.height - 1; + let footer: String = (0..buf.area.width) + .map(|x| cell_text(&buf, x, footer_y)) + .collect(); + assert!( + footer.contains("open") && footer.contains("mode") && footer.contains("? help"), + "expected the curated outline hint string in the footer, got: {footer:?}" + ); + } + + #[test] + fn footer_renders_a_rebound_key_not_the_default() { + use crate::config::RawBinding; + use crate::config::View as CfgView; + use crate::keymap::Keymap; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + assert!(app.notice.is_none()); + + let keymap = Keymap::from_bindings(&[RawBinding { + view: CfgView::Diff, + action: "stage-hunk".to_string(), + keys: "x".to_string(), + }]); + + let backend = TestBackend::new(80, 10); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| render(f, &mut app, &keymap)).unwrap(); + let buf = terminal.backend().buffer().clone(); + + let footer_y = buf.area.height - 1; + let footer: String = (0..buf.area.width) + .map(|x| cell_text(&buf, x, footer_y)) + .collect(); + assert!( + footer.contains("x stage") && !footer.contains("s stage"), + "expected the REBOUND key in the footer, got: {footer:?}" ); } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 0d01d53..af1554b 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -56,6 +56,7 @@ pub fn next_event(timeout: Duration) -> io::Result> { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Action { Quit, + ToggleHelp, MoveCursorBy(i64), ScrollTop, ScrollBottom, @@ -91,6 +92,7 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { match command { Command::Quit => Action::Quit, Command::ToggleOutline => Action::ToggleOutline, + Command::ToggleHelp => Action::ToggleHelp, Command::CursorDown => Action::MoveCursorBy(1), Command::CursorUp => Action::MoveCursorBy(-1), Command::HalfPageDown => Action::MoveCursorBy(half_page), @@ -163,6 +165,7 @@ fn map_key( fn apply_action(app: &mut App, action: Action) -> bool { match action { Action::Quit => return true, + Action::ToggleHelp => app.toggle_help(), Action::MoveCursorBy(delta) => app.move_cursor_by(delta), Action::ScrollTop => app.scroll_top(), Action::ScrollBottom => app.scroll_bottom(), @@ -200,23 +203,29 @@ fn apply_action(app: &mut App, action: Action) -> bool { /// message and performs its normal action. `Resize`/`Tick` do NOT clear it: a redraw or timer /// tick isn't the user acting on the message. /// -/// Esc precedence (highest first): a pending discard confirm > the outline having focus > an -/// active line selection > the normal key map (where Esc quits). Concretely: +/// Esc precedence (highest first): a pending discard confirm > the help overlay being open > the +/// outline having focus > an active line selection > the normal key map (where Esc quits). +/// Concretely: /// /// 1. A pending discard confirm captures the keyboard FIRST (before the notice clear and the /// normal key map): `y` accepts, `n`/`Esc` cancels, and every other key is swallowed — a modal /// that neither clears the notice nor runs a normal action while it's up. -/// 2. Otherwise, while the outline pane has focus, Esc returns focus to the diff (via the normal +/// 2. Otherwise, the help overlay (`?`) captures the keyboard next, mirroring the confirm modal's +/// swallow: `?`/`q`/`Esc` close it, every other key is a no-op (nothing on the diff behind it +/// reacts). Ranked just below the confirm modal — in practice the two are never up +/// together, since opening help doesn't run through a confirm, but the confirm winning keeps +/// a destructive prompt from ever being silently dismissed by a stray overlay key. +/// 3. Otherwise, while the outline pane has focus, Esc returns focus to the diff (via the normal /// map's `outline_focused` branch — see [`map_key`]) rather than quitting or falling into the /// selection-cancel case below (locked design: "Esc must still not quit when the outline has /// focus"). The selection-Esc arm below is guarded to defer to this case. -/// 3. Otherwise, with an active line selection, Esc CANCELS the selection instead of quitting (`q` +/// 4. Otherwise, with an active line selection, Esc CANCELS the selection instead of quitting (`q` /// still quits). Other keys fall through to the normal map — `j`/`k` extend the selection, /// `s`/`d` act on it. -/// 4. Otherwise the normal map applies, where Esc (like `q`) quits. +/// 5. Otherwise the normal map applies, where Esc (like `q`) quits. /// -/// A `Key` event clears any showing footer notice before applying its own action (cases 2-4); the -/// confirm modal (case 1) deliberately does not. +/// A `Key` event clears any showing footer notice before applying its own action (cases 3-5); the +/// confirm and help modals (cases 1-2) deliberately do not. fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: AppEvent) -> bool { match event { AppEvent::Key(key) if app.pending_confirm.is_some() => { @@ -229,6 +238,13 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap } false } + AppEvent::Key(key) if app.help_visible => { + match key.code { + KeyCode::Char('?') | KeyCode::Char('q') | KeyCode::Esc => app.toggle_help(), + _ => {} + } + false + } AppEvent::Key(key) if app.selection_anchor.is_some() && key.code == KeyCode::Esc @@ -311,7 +327,7 @@ fn event_loop( let mut quit = false; loop { - terminal.draw(|f| render::render(f, app))?; + terminal.draw(|f| render::render(f, app, keymap))?; if quit { return Ok(()); @@ -993,4 +1009,132 @@ mod tests { "Enter returns focus to the diff after jumping" ); } + + // ── CS3: help overlay ─────────────────────────────────────────────────── + + #[test] + fn question_mark_opens_the_help_overlay() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + assert!(!app.help_visible); + + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Char('?'))), + ); + assert!(app.help_visible, "? opens the help overlay"); + } + + #[test] + fn while_help_is_open_other_keys_are_swallowed() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + app.toggle_help(); + assert!(app.help_visible); + let cursor_before = app.cursor; + + // `j` would normally move the cursor — while help is up it must be a pure no-op, exactly + // like the pending-confirm modal's swallow. + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Char('j'))), + ); + + assert!(!quit); + assert!( + app.help_visible, + "an unrelated key must not close the overlay" + ); + assert_eq!( + app.cursor, cursor_before, + "a swallowed key must not run its normal action" + ); + } + + #[test] + fn question_mark_q_and_esc_all_close_the_help_overlay() { + use git_workon_fixture::prelude::*; + + for close_key in [ + key(KeyCode::Char('?')), + key(KeyCode::Char('q')), + key(KeyCode::Esc), + ] { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + app.toggle_help(); + assert!(app.help_visible); + + let quit = update(&mut app, &km, &mut pending, AppEvent::Key(close_key)); + + assert!(!quit, "closing help must not also quit the app"); + assert!( + !app.help_visible, + "{close_key:?} must close the help overlay" + ); + } + } + + #[test] + fn a_pending_confirm_still_wins_over_an_open_help_overlay() { + use git_workon_fixture::prelude::*; + use workon_review::app::PendingOp; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + app.toggle_help(); + app.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); + + // `y` while BOTH modals are up must resolve the confirm (case 1 wins per `update`'s + // documented precedence), not close help or fall through to a normal action. + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Char('y'))), + ); + + assert!( + app.pending_confirm.is_none(), + "the confirm modal must capture y first" + ); + assert!( + app.help_visible, + "the confirm arm must not have touched help_visible" + ); + } } From 050db4358e6f62380320ef45cb367c483c5684fe Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 01:52:20 -0400 Subject: [PATCH 050/203] feat(review): configure outline width/mode and diff layout/zoom --- git-workon-review/src/app.rs | 315 +++++++++++++++++++++++++++++++- git-workon-review/src/config.rs | 27 +++ git-workon-review/src/main.rs | 23 ++- git-workon-review/src/render.rs | 4 +- 4 files changed, 361 insertions(+), 8 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 9dcd901..a129d0d 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -18,6 +18,7 @@ use workon::{Changeset, ChangesetSource}; use crate::acquire::{ChangesetDiff, WorktreeDiffs}; use crate::align::{align_file, collapse_gaps, inline_rows, CellKind, DisplayRow, InlineRow, Row}; use crate::apply::{Git2Applier, StageVerb}; +use crate::config::RawViewConfig; use crate::highlight::{FgSpan, TsHighlighter}; use crate::model::{DiffModel, FileChange, FileStatus, Hunk, LineKind}; use crate::ops; @@ -344,6 +345,19 @@ fn read_workdir_file(repo: &Repository, path: &str) -> String { .unwrap_or_default() } +/// Default outline pane width (locked design: "~35 cols") — the CS7 +/// (`workon.review.outline.width`) fallback when the setting is unset, out of range, or the +/// config read fails. Was a `render.rs`-local const before CS7; now App-owned state since it's +/// configurable per session (see [`OutlineState::width`]). +pub const DEFAULT_OUTLINE_WIDTH: u16 = 35; +/// Sane clamp bounds for `workon.review.outline.width` (CS7). Below `MIN_OUTLINE_WIDTH` the +/// pane can't show a useful path fragment; above `MAX_OUTLINE_WIDTH` it would swallow the diff +/// pane on any reasonable terminal. Also addresses M5's deferred narrow-terminal papercut: a +/// user on a narrow terminal can now set a smaller width instead of losing the diff pane +/// entirely to a fixed 35-col outline. +pub const MIN_OUTLINE_WIDTH: u16 = 10; +pub const MAX_OUTLINE_WIDTH: u16 = 200; + /// Which layout the renderer draws the current file's rows in — runtime-toggled via `L` /// (prototype analog: `rl`), and persists across file navigation (neither /// [`App::next_file`]/[`App::prev_file`] nor [`App::open_current`] touch it). @@ -440,6 +454,43 @@ 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, + } +} + +/// 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, + } +} + +/// 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, + } +} + /// The outline side pane's own state (locked fork 3): whether it's showing, whether IT (rather /// than the diff) currently has keyboard focus, its own cursor (an index into /// [`App::outline_items`]'s row list — a wholly separate coordinate space from [`App::cursor`]), @@ -451,6 +502,9 @@ pub struct OutlineState { pub focused: bool, pub cursor: usize, pub mode: OutlineMode, + /// The outline pane's column width — `workon.review.outline.width` (CS7), defaulting to + /// [`DEFAULT_OUTLINE_WIDTH`]. Read by `render.rs` in place of the old fixed const. + pub width: u16, } /// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the @@ -759,6 +813,7 @@ impl App { focused: false, cursor: 0, mode: OutlineMode::default(), + width: DEFAULT_OUTLINE_WIDTH, }; let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial @@ -1269,6 +1324,18 @@ impl App { self.open_current(); } + /// Set the requested zoom directly — the config-startup (CS7) counterpart to + /// [`Self::cycle_zoom`]. Skips `cycle_zoom`'s committed-changeset guard: that guard exists + /// only to give interactive feedback when a cycle would be a no-op, not to enforce the + /// invariant itself — [`Self::effective_zoom_for`] (driven from [`Self::open_current`]'s + /// `reset_panes`, which [`Self::apply_view_config`]'s caller runs right after this) already + /// collapses a non-stageable changeset to [`Role::Combined`] regardless of the requested + /// zoom, so setting the raw value here can never bypass the gate. Does NOT call + /// `open_current` itself — the caller applies every CS7 setting first, then opens once. + pub fn set_zoom(&mut self, zoom: Zoom) { + self.zoom = zoom; + } + /// Swap focus between the two split panes (`w`) — swaps `cursor`/`scroll`/`pane_height` with /// the stashed unfocused pane so the existing cursor methods keep driving the focused pane, and /// re-derives the newly focused pane's scroll against its own (just-swapped-in) height. A no-op @@ -1419,6 +1486,13 @@ impl App { self.outline.cursor } + /// The outline pane's column width — `workon.review.outline.width` (CS7), or + /// [`DEFAULT_OUTLINE_WIDTH`] if never set. Read by `render.rs` in place of the old fixed + /// const. + pub fn outline_width(&self) -> u16 { + self.outline.width + } + pub fn outline_mode(&self) -> OutlineMode { self.outline.mode } @@ -1462,6 +1536,23 @@ impl App { self.sync_outline_to_current(); } + /// Set the outline pane width directly (CS7: `workon.review.outline.width`, applied by + /// [`Self::apply_view_config`] at startup — there's no interactive key for this today). The + /// caller is responsible for clamping into `[MIN_OUTLINE_WIDTH, MAX_OUTLINE_WIDTH]` + /// (`apply_view_config` does); this setter trusts its input. + pub fn set_outline_width(&mut self, width: u16) { + self.outline.width = width; + } + + /// Set the outline mode directly — the config-startup (CS7) counterpart to + /// [`Self::outline_cycle_mode`]. Unlike the interactive cycle, this does NOT call + /// [`Self::sync_outline_to_current`]: [`Self::apply_view_config`] runs before the first + /// [`Self::open_current`], matching how [`Self::from_changesets`] seeds + /// [`OutlineState::mode`] today (the outline cursor starts at `0` either way). + pub fn set_outline_mode(&mut self, mode: OutlineMode) { + self.outline.mode = mode; + } + /// Move the outline's own cursor by `delta` rows (`j`/`k` while the outline has focus), /// clamped into the current row list. Landing on a FILE row jumps the diff there /// immediately (outline -> diff, per the locked design); landing on a HEADER row does NOT @@ -1718,6 +1809,83 @@ impl App { self.derive_scroll(); } + /// Set the render layout directly — the config-startup (CS7) counterpart to + /// [`Self::toggle_layout`]. Called before the first [`Self::open_current`], whose + /// `reset_panes` derives `cursor`/`scroll` fresh for whichever layout is active, so — + /// unlike `toggle_layout`, which must clamp an EXISTING cursor into the new layout's row + /// count — no separate clamp is needed here. Does NOT call `open_current` itself — the + /// caller applies every CS7 setting first, then opens once. + pub fn set_layout(&mut self, layout: Layout) { + 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 + /// (see each setter's doc comment for why that's enough to stay on the gated path). Call + /// once, right after construction and before [`Self::open_current`] (see `main.rs`) — the + /// setters here don't themselves re-derive `cursor`/`scroll`, and the caller's + /// `open_current` is what does that for whichever settings just landed. + /// + /// `raw` is read via [`crate::config::ReviewConfig::view_config`] BEFORE `repo` moves into + /// `App` (see `main.rs`) — its fields already collapsed an unset setting and a config-read + /// error to the same `None` (CS7 applies the current hardcoded default for either case, no + /// warning). Each setting additionally falls back to the default when SET but invalid — out + /// of range (width), or an unrecognized string (mode/layout/zoom) — collecting a warning for + /// those cases, same non-fatal posture as the keymap/theme resolution (ADR-028). + pub fn apply_view_config(&mut self, raw: &RawViewConfig) -> Vec { + let mut warnings = Vec::new(); + + let width = match raw.outline_width { + Some(w) => match u16::try_from(w) { + Ok(w) if (MIN_OUTLINE_WIDTH..=MAX_OUTLINE_WIDTH).contains(&w) => w, + _ => { + warnings.push(format!( + "workon.review.outline.width = {w} out of range \ + ({MIN_OUTLINE_WIDTH}-{MAX_OUTLINE_WIDTH}); using default" + )); + DEFAULT_OUTLINE_WIDTH + } + }, + None => DEFAULT_OUTLINE_WIDTH, + }; + 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() + }), + None => OutlineMode::default(), + }; + self.set_outline_mode(mode); + + 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() + }), + 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() + }), + None => Zoom::default(), + }; + self.set_zoom(zoom); + + warnings + } + /// Set a transient footer notice (see [`Self::notice`]'s doc comment). Overwrites any /// currently-showing notice rather than queuing — only one message is ever on screen. pub fn notify(&mut self, text: impl Into, severity: Severity) { @@ -2423,8 +2591,12 @@ mod tests { use workon::{Changeset, ChangesetSource}; use super::test_support::app_from_fixture; - use super::{find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, EffectiveZoom, Role}; + use super::{ + find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, EffectiveZoom, Layout, Role, + Zoom, DEFAULT_OUTLINE_WIDTH, + }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; + use crate::config::ReviewConfig; use crate::model::FileStatus; use crate::outline::{OutlineItem, OutlineMode, StagedStatus}; @@ -5119,4 +5291,145 @@ mod tests { app.toggle_outline(); assert!(!app.outline_open()); } + + // ── CS7: view-config (`apply_view_config`) ───────────────────────────────── + + #[test] + fn unset_view_config_keeps_current_defaults() { + let fixture = FixtureBuilder::new().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.outline_width(), DEFAULT_OUTLINE_WIDTH); + assert_eq!(app.outline_mode(), OutlineMode::default()); + assert_eq!(app.layout, Layout::default()); + assert_eq!(app.zoom, Zoom::default()); + } + + #[test] + fn outline_width_overrides_default_when_set() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.width", "40") + .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.outline_width(), 40); + } + + #[test] + fn outline_width_out_of_range_falls_back_to_default_with_warning() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.width", "9999") + .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.outline_width(), DEFAULT_OUTLINE_WIDTH); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("outline.width")); + } + + #[test] + fn outline_mode_overrides_default_when_set() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.mode", "tree") + .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.outline_mode(), OutlineMode::Tree); + } + + #[test] + fn outline_mode_invalid_falls_back_to_default_with_warning() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.mode", "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.outline_mode(), OutlineMode::default()); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("outline.mode")); + } + + #[test] + fn diff_layout_overrides_default_when_set() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.layout", "inline") + .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.layout, Layout::Inline); + } + + #[test] + fn diff_layout_invalid_falls_back_to_default_with_warning() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.layout", "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.layout, Layout::default()); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("diff.layout")); + } + + #[test] + fn diff_zoom_overrides_default_when_set() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.zoom", "staged") + .build() + .unwrap(); + let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); + let mut app = app_from_fixture(&fixture); + + let warnings = app.apply_view_config(&config); + + assert!(warnings.is_empty()); + assert_eq!(app.zoom, Zoom::Staged); + } + + #[test] + fn diff_zoom_invalid_falls_back_to_default_with_warning() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.zoom", "bogus") + .build() + .unwrap(); + let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); + let mut app = app_from_fixture(&fixture); + + let warnings = app.apply_view_config(&config); + + assert_eq!(app.zoom, Zoom::default()); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("diff.zoom")); + } } diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index c89b820..045b3e4 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -94,6 +94,17 @@ pub struct RawBinding { pub keys: String, } +/// The four CS7 view-config settings, read raw (unset → `None`) and owned — see +/// [`ReviewConfig::view_config`]. Validation (range/enum checks) and default fallback are +/// [`crate::app::App::apply_view_config`]'s job, same division as [`RawBinding`]/CS2. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RawViewConfig { + pub outline_width: Option, + pub outline_mode: Option, + pub diff_layout: Option, + pub diff_zoom: Option, +} + /// Decompose a fully-qualified config variable name (as returned by /// [`git2::ConfigEntry::name`]) into its (view, action) components, per ADR-028's grammar: /// bare `workon.review.bind.` is the global keymap; `workon.review..bind.` @@ -202,6 +213,22 @@ impl<'repo> ReviewConfig<'repo> { self.get_view_string(View::Diff, "zoom") } + /// 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 + /// identically (both apply the current hardcoded default). Exists so `main.rs` can read + /// view config into an owned value BEFORE `repo` moves into `App` (mirroring how the + /// keymap/theme are resolved before the move), rather than holding a `ReviewConfig<'repo>` + /// (which borrows `repo`) alongside the `App` that owns it. + pub fn view_config(&self) -> RawViewConfig { + RawViewConfig { + outline_width: self.outline_width().ok().flatten(), + outline_mode: self.outline_mode().ok().flatten(), + diff_layout: self.diff_layout().ok().flatten(), + diff_zoom: self.diff_zoom().ok().flatten(), + } + } + /// 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). diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 39e454a..b5cef13 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -54,16 +54,31 @@ fn main() -> Result<()> { Err(_) => Keymap::defaults(), }; + // 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(); + // `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 = App::from_changesets(repo, views); + + // Apply CS7's view-config settings BEFORE `open_current`: `App::apply_view_config`'s setters + // only set the raw layout/zoom/mode/width fields, and `open_current` is what derives + // `cursor`/`scroll` fresh from whichever settings just landed (see each setter's doc + // comment). + let view_config_warnings = app.apply_view_config(&view_config); app.open_current(); - // A misconfigured keybinding 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. - if !keymap.warnings().is_empty() { - app.notify(keymap.warnings().join("; "), Severity::Error); + // 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. + let mut warnings = keymap.warnings().to_vec(); + warnings.extend(view_config_warnings); + if !warnings.is_empty() { + app.notify(warnings.join("; "), Severity::Error); } tui::run(&mut app, &keymap).into_diagnostic()?; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 19e36a0..cc98b2b 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -60,8 +60,6 @@ const FG_CURRENT: Color = Color::Rgb(96, 200, 128); /// [`BG_CURSOR`] so the outline's remembered position stays legible without competing with the /// diff's own (focused) cursor row for visual weight. const BG_OUTLINE_CURSOR_UNFOCUSED: Color = Color::Rgb(35, 38, 55); -/// Fixed column width of the outline side pane (locked design: "~35 cols"). -const OUTLINE_WIDTH: u16 = 35; /// Blend the cursor row's tint into an existing background, so the cursor highlight composites /// with (rather than replaces) del/add/word-diff emphasis on the same row — the row highlight is @@ -369,7 +367,7 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap) { let hlayout = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Length(OUTLINE_WIDTH), + Constraint::Length(app.outline_width()), Constraint::Length(1), Constraint::Min(1), ]) From b84ad106fdcab34c3244112120c4a83aba5cfcc1 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 08:56:15 -0400 Subject: [PATCH 051/203] fix(review): read committed changeset new side from head tree --- git-workon-review/src/app.rs | 131 +++++++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 4 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index a129d0d..fb57e31 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -97,11 +97,14 @@ impl FileView { /// render one revision on one side and a different one on the other: /// - old side: [`Role::Combined`]/[`Role::Staged`] read the `HEAD` blob; [`Role::Unstaged`] /// reads the INDEX blob (unstaged is index ↔ worktree). - /// - new side: [`Role::Combined`]/[`Role::Unstaged`] read the worktree file; - /// [`Role::Staged`] reads the INDEX blob (staged is `HEAD` ↔ index). + /// - new side: [`Role::Combined`]/[`Role::Unstaged`] read the worktree file when `new_tree` + /// is `None` (the uncommitted layer); for a committed changeset `new_tree` is the changeset's + /// `head` commit tree, whose blob is read instead (its new side is `base..head`, not the + /// current worktree). [`Role::Staged`] reads the INDEX blob (staged is `HEAD` ↔ index). fn load( repo: &Repository, head_tree: &git2::Tree<'_>, + new_tree: Option<&git2::Tree<'_>>, file: &FileChange, role: Role, ts: &mut TsHighlighter, @@ -118,7 +121,10 @@ impl FileView { let new_text = match file.status { FileStatus::Deleted => String::new(), _ => match role { - Role::Combined | Role::Unstaged => read_workdir_file(repo, &file.path), + Role::Combined | Role::Unstaged => match new_tree { + Some(tree) => read_head_blob(repo, tree, &file.path), + None => read_workdir_file(repo, &file.path), + }, Role::Staged => read_index_blob(repo, &file.path), }, }; @@ -315,6 +321,24 @@ fn old_side_tree_for(repo: &Repository, source: ChangesetSource) -> Option Option> { + match source { + ChangesetSource::Committed { head, .. } => { + repo.find_commit(head).and_then(|c| c.tree()).ok() + } + ChangesetSource::Uncommitted => None, + } +} + fn read_head_blob(repo: &Repository, tree: &git2::Tree<'_>, path: &str) -> String { tree.get_path(Path::new(path)) .and_then(|entry| entry.to_object(repo)) @@ -1213,7 +1237,17 @@ impl App { let Ok(head_tree) = self.repo.head().and_then(|h| h.peel_to_tree()) else { return; }; - FileView::load(&self.repo, &head_tree, &file, role, &mut self.highlighter) + // Non-Combined roles are uncommitted-only (committed changesets have empty + // staged/unstaged sub-models), so the new side always stays worktree/index — + // `None` here preserves that exactly. + FileView::load( + &self.repo, + &head_tree, + None, + &file, + role, + &mut self.highlighter, + ) }; self.views_for_mut(role)[idx] = Some(view); return; @@ -1229,15 +1263,22 @@ impl App { let Some(head_tree) = old_side_tree_for(&self.repo, self.cur().cs.source) else { return; }; + // New-side source mirrors the old side: `None` (worktree) for the uncommitted layer, + // the changeset's `head` tree for a committed changeset. Same free-fn borrow dance as + // `old_side_tree_for` — both trees borrow only `self.repo`, so `&mut self.highlighter` + // stays free for `FileView::load`. + let new_tree = new_side_tree_for(&self.repo, self.cur().cs.source); let file = self.cur().diff.files[idx].clone(); let view = FileView::load( &self.repo, &head_tree, + new_tree.as_ref(), &file, Role::Combined, &mut self.highlighter, ); drop(head_tree); + drop(new_tree); self.cur_mut().views_combined[idx] = Some(view); } @@ -4721,6 +4762,88 @@ mod tests { assert_eq!(app.current, 0); } + /// Regression: navigating to an OLDER committed changeset and loading its combined view must + /// source the new side from that changeset's `head` commit tree, not the current worktree. The + /// same file `f.txt` is touched by both changesets, so `cs-a`'s head (`mid`) content differs + /// from the worktree (which holds `head`'s content). Before the `new_side_tree_for` fix the new + /// side read the worktree, whose line count disagreed with `cs-a`'s `base..head` hunks and + /// tripped the align invariant (align.rs:165 "trailing context ... must be equal length"). No + /// color pinning needed: `new_text()` returns the raw blob text, not highlighted spans. + #[test] + fn older_committed_changesets_new_side_reads_its_head_tree_not_the_worktree() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let root = fixture + .commit("main") + .file("f.txt", "one\n") + .create("root") + .unwrap(); + // cs-a (root..mid) adds "two" to f.txt — its head-tree copy is "one\ntwo\n". + let mid = fixture + .commit("main") + .file("f.txt", "one\ntwo\n") + .create("mid") + .unwrap(); + // cs-b (mid..head) adds "three" — so the checked-out worktree copy is "one\ntwo\nthree\n", + // three lines, which must NOT be what cs-a's combined new side reads. + let head = fixture + .commit("main") + .file("f.txt", "one\ntwo\nthree\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + + let cs_a = Changeset { + name: "cs-a".to_string(), + source: ChangesetSource::Committed { + base: root, + head: mid, + }, + title: None, + current: false, + needs_restack: false, + }; + let cs_b = Changeset { + name: "cs-b".to_string(), + source: ChangesetSource::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_eq!(app.current_cs(), 1, "opens on cs-b (its current: true)"); + + // Navigate back to the older changeset and load its combined view. Pre-fix this panics at + // align.rs:165; post-fix it loads cleanly. + app.prev_changeset(); + assert_eq!(app.current_cs(), 0, "prev lands on cs-a"); + let view = app.current_view().expect("cs-a's combined view must load"); + + assert_eq!( + view.new_text(), + "one\ntwo\n", + "new side must read cs-a's head (mid) blob, not the worktree copy" + ); + assert_ne!( + view.new_text(), + "one\ntwo\nthree\n", + "new side must NOT read the worktree (which holds cs-b's head content)" + ); + } + #[test] fn bracket_c_jumps_to_the_adjacent_changesets_first_file() { let mut app = two_committed_changesets_two_and_one_files(); From 87b75d8d5320125a47c456aaa0c18d7f2bec6c58 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 11:55:51 -0400 Subject: [PATCH 052/203] =?UTF-8?q?docs(review):=20reprioritize=20roadmap?= =?UTF-8?q?=20=E2=80=94=20daily-driver=20first,=20M7=20review-any-source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rfc/workon-review.md | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 1f6fa82..53dc698 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -13,7 +13,7 @@ It is the productization of a working Neovim prototype (`~/.config/nvim/lua/app/ | Decision | Outcome | |---|---| -| Positioning | Changeset review tool; not a lazygit competitor. Comments-to-agent is a first-class capability, not a stretch. | +| Positioning | Changeset review tool; not a lazygit competitor. Comments-to-agent is a first-class capability, not a stretch. **Reprioritized 2026-07-08 (direction B):** near-term goal is the author's own daily diff-review + git driver; the agent-loop/comments become the eventual payoff, not the next work. See "Roadmap reprioritized" under Milestones. | | Home | This workspace, as sibling crate `git-workon-review`. | | Crate layout | ONE crate, lib+bin targets. lib = review domain (diff parse, word-diff, staging, changeset views); bin = TUI + `mcp` subcommand. No separate core crate until a second consumer exists. | | Name | Package == binary == `git-workon-review`. `git workon-review` works via git's native `git-*` dispatch. (`git-review` is squatted on crates.io + Gerrit-loaded; `docket` too docker-adjacent; bare `review` superseded by suite framing; `signoff` was the free runner-up.) | @@ -25,7 +25,7 @@ It is the productization of a working Neovim prototype (`~/.config/nvim/lua/app/ | Fixture | `git-workon-fixture` is the test substrate for both crates. Extend it: SQLite-format graphite metadata mode (the sqlite read path is currently fixture-untested — builder only writes legacy refs blobs) and index-state builders (staged/unstaged/untracked combos). | | Highlighting | tree-sitter (tree-sitter-highlight), syntect as long-tail fallback. Measured: ts ~0.01ms/line vs syntect ~0.19ms/line, and better output. Grammar set + gotchas are in the spike. | | View model | Full parity with the prototype's four zoom states (split/combined/unstaged/staged + attributed rendering). If v1 must shrink, cut zoom states — never the comments loop. | -| v1 sources | uncommitted, stack, ref/range. PR deferred (git-workon-lib's `pr.rs` covers much of it later). | +| v1 sources | uncommitted, stack, ref/range, **PR** — all folded into **M7 "review any source"** (PR was deferred; now first, via git-workon-lib's `pr.rs`). | | Comments | MCP: on-disk comment store (`.review/` JSON or sqlite) + `git-workon-review mcp` stdio subcommand serving get/resolve tools; TUI watches the store. Degrades to a plain file convention for non-MCP harnesses. | | Edit flow | Embedded: `nvim --server $NVIM --remote + `. Standalone: `$EDITOR`. File watcher refreshes on save. | | Completions | Full clap_complete (unstable-dynamic, already a workspace dep) on the direct binary. Work item: git-workon's dynamic completer enumerates `git-workon-*` on PATH and delegates post-subcommand completion via `COMPLETE= git-workon-review -- `. Git-level shims: on demand only. | @@ -134,9 +134,25 @@ evidence, not to the conclusion. - **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). Design locked 2026-07-07 (plan artifact `cairn-ledger`, 9 forks): (1) source = per-changeset `ChangesetView`, committed changesets built via `DiffState::from_committed` (empty staged/unstaged sub-models); (2) mode = derived `is_committed` + targeted guards, leaning on the existing `effective_zoom` collapse (empty sub-diffs → combined-only for free); (3) outline = left side pane, all four modes (flat/tree/stack/stack-tree); (4) load = hybrid (eager per-changeset `DiffState`, lazy per-file `FileView`); (5) nav = continuous `]f`/`[f` across the stack + `]c`/`[c` changeset jumps; (6) open-at = honor the lib's `current` flag; (7) source scope = auto-detect Graphite else single uncommitted changeset (M2–M4 preserved, backward-compatible); (8) changeset indicator = new top winbar; (9) needs-restack = first-class glyph + amber color (the lib gives a real boolean, unlike the prototype's title-string suffix). — DONE (2026-07-07): shipped as FOUR changesets `m5-stack-source → m5-changeset-nav → m5-outline-core → m5-outline-tree`, each delegated to an `implementer` subagent and main-thread diff-read before the next landed. The M1 lib already provided `assemble_changesets` + the `diff_changeset` router, so M5 was almost entirely review-App wiring; the uncommitted layer becomes one changeset *inside* the stack, keeping all of M4's staging/zoom/attribution working on it while committed changesets render read-only. Two correctness fixes surfaced during implementation, neither in the plan: (a) a committed changeset's combined-role old side must read its `base` commit's tree, not live `HEAD` (`old_side_tree_for`); (b) skipping attribution for committed changesets is not just a guard — without it `Attribution::build(None, None)`'s empty sets miscolored every Add cell as "already staged" (dim), pinned by a render test. Acceptance met: dogfooded against this repo's own live 33-changeset Graphite stack via a PTY harness (winbar changeset counter, `]c`/`[c` nav, outline flat/stack/tree/stack-tree modes with correct tree guides, open-on-uncommitted-layer focus) — a clean exit, no panic, exercising the real `resolve_changesets`→`assemble_graphite` path the hand-built unit tests don't. Full workspace green (41 suites, 804 tests, 0 fail), clippy `-D warnings --all-targets --all-features` clean. Deferred: Git-inference (`StackModel::Git`) and explicit ref-range review (the broader "ref sources") — auto-detect ships Graphite-or-uncommitted only; a fixed 35-col outline with no narrow-terminal handling. - **M6 — git-workon CLI integration.** Ordered first: dependency-free, lowest-risk, and it unlocks dogfooding every later milestone through the real `git workon review` entry point (not `cargo run`). Cargo-style external-subcommand dispatch — `git-workon`'s unknown subcommand execs `git-workon-` on PATH with args passed through (none exists today; `Cmd` is a closed enum), so `git workon review` works via git's native `git-*` dispatch. Plus completion: the review binary gains `CompleteEnv` (its `Cli` is currently empty) so it is a `COMPLETE=` responder, and git-workon's dynamic completer enumerates `git-workon-*` on PATH and surfaces them as top-level subcommand candidates (so `git workon ` offers `review`). **Post-subcommand sub-delegation** (`git workon review ` → shell out to the review binary's completer) is **deferred, not built**: the review binary's `Cli` is currently empty (zero candidates), and MCP lands as `git workon mcp` (not a review subcommand — see M9), so there is nothing to delegate today. Its real trigger is *not* MCP — it's whenever the review binary gains its source-selector arg (`stack | uncommitted | | | pr-####`, the deferred v1 sources), whose values (refs, ranges, PR numbers) are genuinely completion-worthy. Wire delegation then, against that real surface; the review binary is already a `COMPLETE=` responder, so only the git-workon-side shell-out remains. Acceptance: `git workon review` dispatches with args through; `git workon ` lists external subcommands including `review`. DONE (2026-07-07): shipped as THREE changesets `m6-dispatch → m6-review-complete → m6-complete-enum` — (1) manual pre-parse PATH intercept (`dispatch.rs`), NOT clap `allow_external_subcommands` (which would break the flattened-`find.name` default-command routing); (2) review binary as `COMPLETE=` responder; (3) top-level external enumeration in the completer. Two seam facts surfaced: the clap_complete bash protocol needs `_CLAP_COMPLETE_INDEX` (word position) or it emits "no completion generated", and an empty `Cli` yields zero candidates (which is what made sub-delegation pointless to build). - **M6.5 — everyday-usability pass (keybindings + theming + view-config).** Inserted ahead of M7 (2026-07-07): comments are deprioritized until the tool is usable for the author's own everyday review work. Keybindings and theming were never milestones — they were baked in as hardcoded values during M3–M5 (a `match` in `tui.rs`, a `const … Color::Rgb` block in `render.rs`). This pass makes both user-configurable and adds discoverability, plus gives previously-hardcoded view settings a config home. Design locked 2026-07-07; two ADRs: [ADR-028](../adr/028-review-git-native-config-schema.md) (git-native config schema — `workon.review.*`, action-as-key per-view keymaps, token grammar) and [ADR-029](../adr/029-review-theming-base16-hybrid.md) (hybrid base16 theming, render-time color resolution, terminal-derived `auto`). Scope: (1) `ReviewConfig` reader — the review binary reads git config for the first time; (2) action registry + configurable per-view keymaps, defaults unchanged; (3) help surface (persistent curated per-view footer + `?` overlay); (4) base16 `Theme` primitive + render-time resolution refactor (`FgSpan` carries capture index); (5) curated dark+light schemes + `theme=dark|light`; (6) `theme=auto` terminal-derivation OSC probe with curated fallback; (7) view-config (`outline.width`/`mode`, `diff.layout`/`zoom`). Full plan: `docs/plans/review-usability-pass.md`. Acceptance: rebind any diff/outline/global action via `git config`; `?` overlay + footer render the resolved map; `theme` selects auto/dark/light with terminal-derived `auto` degrading to curated on probe failure; view defaults honored from config. Comments (M7) resume after. -- **M7 — review comments.** On-disk comment store (`.review/`, JSON-or-sqlite; both deps already in the workspace) keyed to changeset/path/side/line, with a **rebase-survival anchoring strategy** — the central greenfield fork (the frozen prototype has *no* comment store, MCP, or editor-jump: all three are designed from scratch; it only hands us the `(changeset_id, path, side, lnum)` location model with `head_ref ∈ {SHA, WORKTREE, INDEX}` and no re-anchoring precedent). Plus TUI comment UX: create a comment on a diff line, view inline/in a pane, mark resolved, store-watch refresh. Acceptance: a human reviews a changeset, leaves comments pinned to lines, and they persist + re-anchor across a diff refresh (manual `r` / Tick). **Comment-store home is a first-class M7 fork, not just its schema:** M9's `git workon mcp` (in the `git-workon` crate) must read comments, making git-workon a *second consumer* of the store — so it cannot live inside the review binary. It belongs in a lib both the review crate and git-workon can depend on (git-workon-lib, or a new shared crate). This reopens the RFC's deferred "no separate core crate until a second consumer exists" decision — resolve it here. -- **M8 — edit flow.** Editor-jump from a diff line to the file on disk — embedded `nvim --server $NVIM --remote + `, standalone `$EDITOR + ` (detect via `$NVIM`); file watcher refreshes the diff (and re-anchors comments) on external save — port the prototype's debounced repo-root watcher behavior (`FocusGained` fallback, viewport-preserving refresh, selection clamp; the Neovim mechanism doesn't translate, the behavior does). Ordered right after comments so watch-refresh and comment re-anchoring co-develop and stress-test the M7 anchor model immediately. Acceptance: jump opens the right file+line; saving refreshes the diff without losing viewport or comment anchors. -- **M9 — MCP agent loop (`git workon mcp`).** A **first-class `mcp` subcommand of the main `git-workon` binary** (not a review subcommand) starting one stdio MCP server that **bridges both domains**: git-workon-lib worktree tools (`agent-integration.md` Model C — `worktree_create`/`list`/`find`/`remove`/`create_from_pr`) *and* the review comment store (list comments, mark addressed; the TUI reflects changes). One server, one config entry, both capabilities — the unified direction (superseding the earlier "review-comment-only vs unified" fork and the RFC's original `git-workon-review mcp` framing). Deliberately last so the cross-cutting MCP-stack commitment (crate — `rmcp` vs hand-rolled JSON-RPC-over-stdio — transport, error mapping) is made once across both surfaces, and because it depends on the M7 comment store living in a shared lib (see M7). Consequence: `git-workon` gains a dependency on the comment-store lib; the worktree-MCP no longer wants a separate `git-workon-mcp` crate. Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review — plus worktree tools served from the same `git workon mcp`. +### Roadmap reprioritized 2026-07-08 — personal daily-driver first (direction B) + +The remaining roadmap is resequenced around the tool being **the author's own everyday diff-review + git surface**, not the agent-review loop (which becomes the eventual payoff once the tool is lived-in). This **supersedes the "comments next" ordering** and the decision-log **Positioning** / **v1 sources** rows above. Nothing past M6.5 is built, so renumbering is free. The old M7 (comments)/M8 (edit)/M9 (MCP) content is *relocated*, not dropped: edit-flow graduates into the daily-core (new **M10**); comments + MCP defer together into the agent-loop milestone (new **M13**). Ordering rationale is inline per bullet. + +- **Prerequisite — Land M3–M6.5** (process, parallel to features; not a numbered milestone). QA the unmerged M3→M6.5 tower → merge to `main` → reliable install (a local build on PATH is enough to dogfood; the [ADR-027](../adr/027-review-crate-workspace-placement.md) release/homebrew "M3 flip" is a deferrable sub-decision). Gates real daily use regardless of features. QA checklist in memory `review-tui-priority-everyday-use` (`theme=auto` responsiveness, `theme=light` canvas, committed-changeset nav). + +- **M7 — review any source.** A source selector — `stack | uncommitted | | | pr-####` — so the tool reviews *anything*, not just the auto-detected stack/uncommitted state. **Ordered first:** it is the tool's core *read* identity, read-only (low-risk), independent of the write verbs, and the M1/M5 lib already provides `assemble_changesets` + the `diff_changeset` router — mostly source-arg parse → resolve to changeset(s) → existing pipeline. PR support reuses git-workon-lib's `pr.rs`. Also **completes M6's deferred completion sub-delegation** (its trigger was exactly this arg gaining completion-worthy values). Acceptance: `git workon review ` / `` / `pr-123` renders the right changeset(s); `git workon review ` completes sources. + +- **M8 — commit operations.** Commit the staged changes without leaving the TUI — message editor (inline vs `$EDITOR`), Conventional-Commit-aware (enforced by `git-hooks/commit-msg`); **amend** the current commit; **fixup/absorb** staged changes into an earlier changeset in the stack. Closes the review→stage→**commit** loop — the acute daily-driver gap. Acceptance: stage in the TUI, commit/amend/fixup, verified against real git. + +- **M9 — stack operations.** Graphite stack verbs from the TUI: create a changeset/branch from staged (`gt create`), restack (`gt restack`), submit → PRs (`gt submit`), checkout/switch to a changeset (nav exists; actual checkout does not). Advanced reorder/fold/split deferred within. Builds on M8 — commit → create → submit is the shipping spine of a stacked workflow. Acceptance: create/restack/submit/checkout a changeset from the TUI against a real gt stack. + +- **M10 — editor jump / edit flow** *(was M8)*. Jump from a diff line to `file:line` — embedded `nvim --server $NVIM --remote + `, standalone `$EDITOR + ` (detect via `$NVIM`); file watcher refreshes the diff on external save — port the prototype's debounced repo-root watcher (`FocusGained` fallback, viewport-preserving refresh, selection clamp; the Neovim mechanism doesn't translate, the behavior does). **Graduated from agent-loop into daily-core:** under (B) you review and want to *fix* the thing. Acceptance: jump opens the right file+line; saving refreshes without losing viewport. + +- **M11 — polish.** Worktree-switch hub in the TUI (surface git-workon's create/find/prune/switch so the TUI is a hub — vs staying review-only; decide during design) + in-diff navigation (fuzzy jump-to-file, search-in-diff, context expand/collapse, ignore-whitespace toggle, copy `path:line`). Acceptance: per the design cut. + +- **M12 — conflict resolution** *(stretch)*. Resolve merge/rebase conflicts in the SBS view. Large surface; may not make v1. + +- **M13 — agent loop** *(the eventual north star; was M7 comments + M9 MCP)*. On-disk comment store keyed to `(changeset_id, path, side, lnum)` with a rebase-survival anchoring strategy + TUI comment UX (create/view/resolve, store-watch refresh), and a **unified `git workon mcp`** stdio server bridging git-workon-lib worktree tools (`agent-integration.md` Model C) *and* the comment store. **Open forks (unchanged, resolve at design time):** comment-store home — a lib both the review crate and `git-workon` depend on, since `git workon mcp` is a second consumer (reopens the "no separate core crate" decision); the anchoring strategy; MCP crate/transport (`rmcp` vs hand-rolled JSON-RPC-over-stdio). Deferred behind the daily-driver work. ## Orchestration notes From 4dc9aedf591d66cbd37524569114d5ce3deb5df3 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 01:17:48 -0400 Subject: [PATCH 053/203] refactor(review): base16 Theme with render-time color resolution --- docs/adr/029-review-theming-base16-hybrid.md | 22 +- git-workon-review/src/highlight.rs | 107 +++----- git-workon-review/src/lib.rs | 1 + git-workon-review/src/main.rs | 8 +- git-workon-review/src/render.rs | 254 +++++++++++-------- git-workon-review/src/theme.rs | 211 +++++++++++++++ git-workon-review/src/tui.rs | 8 +- 7 files changed, 423 insertions(+), 188 deletions(-) create mode 100644 git-workon-review/src/theme.rs diff --git a/docs/adr/029-review-theming-base16-hybrid.md b/docs/adr/029-review-theming-base16-hybrid.md index 97c6559..3973c84 100644 --- a/docs/adr/029-review-theming-base16-hybrid.md +++ b/docs/adr/029-review-theming-base16-hybrid.md @@ -36,10 +36,24 @@ is spec-conformant. **Primitive — the theme is a base16 scheme.** A `Theme` holds the 16 slots (base00–07 mono ramp + base08–0F accents). Syntax uses the accents via the existing -capture→slot template. Diff-bg tints are **derived**, not authored: blend base08 -(red / spec "Diff Deleted") and base0B (green / spec "Diff Inserted") toward base00 (bg) -using the existing `tint_toward` helper (`render.rs`). Syntax and diff tints therefore come -from one scheme and stay coordinated by construction. +capture→slot template. + +Diff-bg tints ideally come from base08 (red / spec "Diff Deleted") and base0B (green / spec +"Diff Inserted") and the scheme background, so syntax and tints stay coordinated. **But the +derivation is luminance-dependent, not a single "blend toward base00" (corrected in CS4):** +- **Dark (base00 dark):** the shipped M3–M5 tints are more saturated/darker than *any* convex + blend of an accent toward a dark base00 can produce (their green/blue channels sit *below* + base00's). A blend toward a dark base00 also yields muddy mid-tones, not punchy washes. So + the **dark tints are held explicit** in `Theme::dark()` (byte-identical to M3–M5, per the + pixel-identity gate). Deriving them would require scaling the accent toward *black* plus a + desaturation step, not a base00 blend — not worth reverse-engineering the hand-tuned values. +- **Light (base00 light) and terminal-derived:** blending an accent toward a *light* base00 + gives the correct pale tint, so the `tint_toward` derivation applies there (CS5/CS6). A + terminal-derived theme on a *dark* background hits the same problem as dark and needs the + toward-black+desaturate construction — a CS6 concern. + +Net: the scheme-coordinated derivation is real but must branch on background luminance; dark +stays authored. **Mechanism — resolve color at render time, not in the highlight phase.** - `HIGHLIGHT_NAMES` stays global/const: it defines the capture *index space* bound by diff --git a/git-workon-review/src/highlight.rs b/git-workon-review/src/highlight.rs index 403d86e..715bbd6 100644 --- a/git-workon-review/src/highlight.rs +++ b/git-workon-review/src/highlight.rs @@ -7,25 +7,29 @@ use std::collections::HashMap; -use ratatui::style::Color; use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter}; /// Files with more lines than this are skipped (plain fg) to keep /// highlighting fast. pub const MAX_HIGHLIGHT_LINES: usize = 20_000; -/// Foreground color spans for a single line: byte range + color. +/// Foreground syntax span for a single line: a byte range and the semantic *capture index* — +/// the position in [`HIGHLIGHT_NAMES`] of the capture that covers it. The color is resolved at +/// render time against the active [`crate::theme::Theme`] (ADR-029), NOT baked in here: the +/// tree-sitter pass is theme-free and cacheable, and a theme switch recolors by re-rendering. #[derive(Debug, Clone)] pub struct FgSpan { pub start: usize, pub end: usize, - pub color: Color, + /// Index into [`HIGHLIGHT_NAMES`]; resolve via [`crate::theme::Theme::syntax`]. + pub capture: usize, } /// The standard highlight-capture names we recognize. `configure()` matches /// dotted capture names by longest prefix, so e.g. `keyword.control` maps to -/// `keyword`. Parallel with `HIGHLIGHT_COLORS`. -const HIGHLIGHT_NAMES: &[&str] = &[ +/// `keyword`. This is the capture *index space* — theme-invariant (see ADR-029); the +/// per-capture colors live in [`crate::theme`]'s `SYNTAX_SLOTS` template. +pub(crate) const HIGHLIGHT_NAMES: &[&str] = &[ "attribute", "comment", "constant", @@ -56,56 +60,11 @@ const HIGHLIGHT_NAMES: &[&str] = &[ "variable.parameter", ]; -// A small dark theme in the same family as syntect's base16-eighties.dark so -// the two engines look comparable side by side. -const C_RED: Color = Color::Rgb(0xf2, 0x77, 0x7a); -const C_ORANGE: Color = Color::Rgb(0xf9, 0x91, 0x57); -const C_YELLOW: Color = Color::Rgb(0xff, 0xcc, 0x66); -const C_GREEN: Color = Color::Rgb(0x99, 0xcc, 0x99); -const C_CYAN: Color = Color::Rgb(0x66, 0xcc, 0xcc); -const C_BLUE: Color = Color::Rgb(0x66, 0x99, 0xcc); -const C_PURPLE: Color = Color::Rgb(0xcc, 0x99, 0xcc); -const C_FG: Color = Color::Rgb(0xd3, 0xd0, 0xc8); -const C_COMMENT: Color = Color::Rgb(0x74, 0x73, 0x69); - -const HIGHLIGHT_COLORS: &[Color] = &[ - C_ORANGE, // attribute - C_COMMENT, // comment - C_ORANGE, // constant - C_ORANGE, // constant.builtin - C_YELLOW, // constructor - C_FG, // embedded - C_CYAN, // escape - C_BLUE, // function - C_BLUE, // function.builtin - C_BLUE, // function.macro - C_BLUE, // function.method - C_PURPLE, // keyword - C_RED, // label - C_ORANGE, // number - C_FG, // operator - C_CYAN, // property - C_FG, // punctuation - C_FG, // punctuation.bracket - C_FG, // punctuation.delimiter - C_CYAN, // punctuation.special - C_GREEN, // string - C_CYAN, // string.special - C_RED, // tag - C_YELLOW, // type - C_YELLOW, // type.builtin - C_FG, // variable - C_RED, // variable.builtin - C_FG, // variable.parameter -]; - -/// Color for a highlight-capture name, for tests and debugging. -#[cfg(test)] -pub fn color_of(name: &str) -> Option { - HIGHLIGHT_NAMES - .iter() - .position(|n| *n == name) - .map(|i| HIGHLIGHT_COLORS[i]) +/// The capture index for a highlight-capture name — its position in [`HIGHLIGHT_NAMES`], which is +/// exactly what an [`FgSpan::capture`] holds. Used by [`crate::theme`] and tests to relate a named +/// capture to the index the highlighter records. `None` for an unrecognized name. +pub fn capture_index(name: &str) -> Option { + HIGHLIGHT_NAMES.iter().position(|n| *n == name) } fn lang_key_for_ext(ext: &str) -> Option<&'static str> { @@ -279,8 +238,9 @@ impl TsHighlighter { stack.pop(); } HighlightEvent::Source { start, end } => { - let Some(&idx) = stack.last() else { continue }; - let color = HIGHLIGHT_COLORS[idx]; + let Some(&capture) = stack.last() else { + continue; + }; let mut pos = start; while pos < end { let line_idx = line_starts.partition_point(|&s| s <= pos) - 1; @@ -298,7 +258,7 @@ impl TsHighlighter { out[line_idx].push(FgSpan { start: pos - line_start, end: seg_end - line_start, - color, + capture, }); } pos = match line_starts.get(line_idx + 1) { @@ -325,8 +285,10 @@ mod tests { use super::*; #[test] - fn names_and_colors_are_parallel() { - assert_eq!(HIGHLIGHT_NAMES.len(), HIGHLIGHT_COLORS.len()); + fn names_and_syntax_template_are_parallel() { + // The capture index space (`HIGHLIGHT_NAMES`) and the theme's per-capture syntax template + // must stay the same length — a capture with no slot (or vice versa) would panic at render. + assert_eq!(HIGHLIGHT_NAMES.len(), crate::theme::syntax_slot_count()); } #[test] @@ -338,30 +300,33 @@ mod tests { .expect("rust grammar available"); assert_eq!(hl.len(), 3); - // Line 0: `fn` at bytes 0..2 should be keyword-colored. - let kw = color_of("keyword").unwrap(); + // Spans now carry the semantic capture INDEX (color is resolved at render time against the + // theme — see `FgSpan`), so these assert on the capture, not a baked color. + + // Line 0: `fn` at bytes 0..2 should be a keyword capture. + let kw = capture_index("keyword").unwrap(); assert!( hl[0] .iter() - .any(|s| s.start == 0 && s.end >= 2 && s.color == kw), + .any(|s| s.start == 0 && s.end >= 2 && s.capture == kw), "expected keyword span over `fn` on line 0, got {:?}", hl[0] ); - // Line 0: `main` should be function-colored. - let func = color_of("function").unwrap(); + // Line 0: `main` should be a function capture. + let func = capture_index("function").unwrap(); assert!( hl[0] .iter() - .any(|s| { s.color == func && &src[..11][s.start..s.end.min(11)] == "main" }), + .any(|s| { s.capture == func && &src[..11][s.start..s.end.min(11)] == "main" }), "expected function span over `main` on line 0, got {:?}", hl[0] ); - // Line 1: string literal should be string-colored. - let string = color_of("string").unwrap(); + // Line 1: string literal should be a string capture. + let string = capture_index("string").unwrap(); assert!( - hl[1].iter().any(|s| s.color == string), + hl[1].iter().any(|s| s.capture == string), "expected string span on line 1, got {:?}", hl[1] ); @@ -386,10 +351,10 @@ mod tests { } } // The multiline comment should produce comment spans on all 3 lines. - let comment = color_of("comment").unwrap(); + let comment = capture_index("comment").unwrap(); for (i, spans) in hl.iter().enumerate() { assert!( - spans.iter().any(|s| s.color == comment), + spans.iter().any(|s| s.capture == comment), "expected comment span on line {i}" ); } diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index bf6e995..34fa5e1 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -30,4 +30,5 @@ pub mod refresh; pub mod render; pub mod stage_op; pub mod synthesis; +pub mod theme; pub mod wordiff; diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index b5cef13..02f013f 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -8,6 +8,7 @@ use workon_review::acquire::{diff_changeset, resolve_changesets}; use workon_review::app::{App, ChangesetView, Severity}; use workon_review::config::ReviewConfig; use workon_review::keymap::Keymap; +use workon_review::theme::Theme; /// A TUI for reviewing changesets #[derive(Debug, Parser)] @@ -81,7 +82,12 @@ fn main() -> Result<()> { app.notify(warnings.join("; "), Severity::Error); } - tui::run(&mut app, &keymap).into_diagnostic()?; + // CS4 is dark-only and unconditional — a pure refactor with no user-visible change. CS5 wires + // `ReviewConfig::theme()` (config `Theme::{Auto,Dark,Light}`) to pick the palette here; CS6 + // adds the terminal-derivation probe for `auto`. + let theme = Theme::dark(); + + tui::run(&mut app, &keymap, &theme).into_diagnostic()?; Ok(()) } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index cc98b2b..df6e9fb 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -20,35 +20,23 @@ use crate::highlight::FgSpan; use crate::keymap::{footer_hint, help_sections, Keymap}; use crate::model::FileStatus; use crate::outline::OutlineItem; +use crate::theme::Theme; use crate::wordiff::Span as WordSpan; -const BG_DEL_SUBTLE: Color = Color::Rgb(60, 24, 24); -const BG_DEL_STRONG: Color = Color::Rgb(120, 40, 40); -const BG_ADD_SUBTLE: Color = Color::Rgb(20, 48, 24); -const BG_ADD_STRONG: Color = Color::Rgb(32, 100, 48); -/// Dim/desaturated variants of the del/add pair, for staged-ness attribution (locked decision -/// #7): visibly less vivid than the plain pair but still red-tinted, so a staged change reads as -/// "already handled" without disappearing into plain context. -const BG_DEL_STAGED_SUBTLE: Color = Color::Rgb(42, 26, 28); -const BG_DEL_STAGED_STRONG: Color = Color::Rgb(64, 38, 40); -/// Dim/desaturated variants of the add pair — green-tinted counterpart of -/// [`BG_DEL_STAGED_SUBTLE`]/[`BG_DEL_STAGED_STRONG`]. -const BG_ADD_STAGED_SUBTLE: Color = Color::Rgb(24, 34, 26); -const BG_ADD_STAGED_STRONG: Color = Color::Rgb(34, 50, 38); +// The on-tint colors (diff add/del gradient + staged variants, cursor/selection washes, and syntax +// foreground) now come from a [`Theme`] threaded through render (ADR-029). The chrome colors below +// stay ANSI-named / const here: they never sit on a tint, so they inherit the terminal palette and +// self-adapt light/dark, independent of the theme (the hybrid boundary — see the `theme` module). + +/// Default foreground for diff text that carries no syntax highlight — an ANSI gray that inherits +/// the terminal palette (chrome, not on-tint). Syntax-highlighted text resolves its fg from the +/// [`Theme`] instead (see [`compose_segments`]). const FG_DEFAULT: Color = Color::Gray; const FG_DIM: Color = Color::DarkGray; /// Footer text color for an [`Severity::Error`] [`Notice`] — a clearly-red tone that reads on /// both light and dark terminal themes. const FG_ERROR: Color = Color::Rgb(220, 60, 60); const FG_GUTTER: Color = Color::DarkGray; -/// Tint blended into the cursor row's background (see [`blend_bg`]) — a cool slate-blue, chosen -/// to read as "cursor here" without competing with the warm del/add hues above. -const BG_CURSOR: Color = Color::Rgb(45, 50, 90); -/// Tint blended into a SELECTED row's background (line selection, `v`) — a muted teal, distinct -/// from [`BG_CURSOR`]'s slate-blue so a selected-but-not-cursor row reads apart from the cursor -/// row. The cursor row inside a selection keeps the cursor tint (cursor wins on its own row — see -/// [`render_pane_sbs`]). -const BG_SELECTION: Color = Color::Rgb(30, 66, 66); /// Warning tone for the winbar's needs-restack marker (locked decision #9) — an amber, distinct /// from [`FG_ERROR`]'s red: a stale-parent changeset is a heads-up to `gt restack`, not a failure. const FG_WARN: Color = Color::Rgb(214, 158, 46); @@ -56,10 +44,6 @@ const FG_WARN: Color = Color::Rgb(214, 158, 46); /// #9's outline half) — a green, distinct from every other marker color in this module so /// "current" reads unambiguously at a glance. const FG_CURRENT: Color = Color::Rgb(96, 200, 128); -/// Cursor tint for the outline pane while it is OPEN but NOT focused — a dimmer wash than -/// [`BG_CURSOR`] so the outline's remembered position stays legible without competing with the -/// diff's own (focused) cursor row for visual weight. -const BG_OUTLINE_CURSOR_UNFOCUSED: Color = Color::Rgb(35, 38, 55); /// Blend the cursor row's tint into an existing background, so the cursor highlight composites /// with (rather than replaces) del/add/word-diff emphasis on the same row — the row highlight is @@ -100,14 +84,14 @@ fn apply_row_tint(mut line: Line<'static>, width: u16, tint: Color) -> Line<'sta line } -/// Wash the cursor row with [`BG_CURSOR`]. -fn apply_cursor_row(line: Line<'static>, width: u16) -> Line<'static> { - apply_row_tint(line, width, BG_CURSOR) +/// Wash the cursor row with the theme's cursor tint. +fn apply_cursor_row(line: Line<'static>, width: u16, theme: &Theme) -> Line<'static> { + apply_row_tint(line, width, theme.cursor_bg) } -/// Wash a selected (line-selection) row with [`BG_SELECTION`]. -fn apply_selection_row(line: Line<'static>, width: u16) -> Line<'static> { - apply_row_tint(line, width, BG_SELECTION) +/// Wash a selected (line-selection) row with the theme's selection tint. +fn apply_selection_row(line: Line<'static>, width: u16, theme: &Theme) -> Line<'static> { + apply_row_tint(line, width, theme.selection_bg) } /// One resolved (bg, fg) pair for a byte range of a line. @@ -119,11 +103,14 @@ struct Segment { } /// Merge background-role spans and syntax fg spans into a flat list of non-overlapping -/// segments covering `[0, len)`. +/// segments covering `[0, len)`. A syntax span carries only its capture index; its color is +/// resolved HERE against `theme` (ADR-029's render-time resolution) — a segment with no covering +/// syntax span falls back to [`FG_DEFAULT`]. fn compose_segments( len: usize, bg_spans: &[(usize, usize, Color)], fg_spans: Option<&Vec>, + theme: &Theme, ) -> Vec { let mut boundaries: Vec = vec![0, len]; for (s, e, _) in bg_spans { @@ -157,7 +144,7 @@ fn compose_segments( .map(|(_, _, c)| *c); let fg = fg_spans .and_then(|fgs| fgs.iter().find(|s| mid >= s.start && mid < s.end)) - .map(|s| s.color) + .map(|s| theme.syntax(s.capture)) .unwrap_or(FG_DEFAULT); segments.push(Segment { start, end, bg, fg }); } @@ -214,31 +201,37 @@ fn attribution_mode(role: Role, attribution: &Option) -> Attributio } } -/// The (subtle, strong) background pair for a Del cell at `old_lnum`, given `mode`. -fn del_bg_pair(mode: AttributionMode, old_lnum: u32) -> (Color, Color) { +/// 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: &Theme) -> (Color, Color) { + let bright = (theme.del_subtle, theme.del_strong); + let staged = (theme.del_staged_subtle, theme.del_staged_strong); match mode { - AttributionMode::Plain => (BG_DEL_SUBTLE, BG_DEL_STRONG), - AttributionMode::StagedUniform => (BG_DEL_STAGED_SUBTLE, BG_DEL_STAGED_STRONG), + AttributionMode::Plain => bright, + AttributionMode::StagedUniform => staged, AttributionMode::Attributed(attribution) => { if attribution.del_is_staged(old_lnum) { - (BG_DEL_STAGED_SUBTLE, BG_DEL_STAGED_STRONG) + staged } else { - (BG_DEL_SUBTLE, BG_DEL_STRONG) + bright } } } } -/// The (subtle, strong) background pair for an Add cell at `new_lnum`, given `mode`. -fn add_bg_pair(mode: AttributionMode, new_lnum: u32) -> (Color, Color) { +/// 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: &Theme) -> (Color, Color) { + let bright = (theme.add_subtle, theme.add_strong); + let staged = (theme.add_staged_subtle, theme.add_staged_strong); match mode { - AttributionMode::Plain => (BG_ADD_SUBTLE, BG_ADD_STRONG), - AttributionMode::StagedUniform => (BG_ADD_STAGED_SUBTLE, BG_ADD_STAGED_STRONG), + AttributionMode::Plain => bright, + AttributionMode::StagedUniform => staged, AttributionMode::Attributed(attribution) => { if attribution.add_is_unstaged(new_lnum) { - (BG_ADD_SUBTLE, BG_ADD_STRONG) + bright } else { - (BG_ADD_STAGED_SUBTLE, BG_ADD_STAGED_STRONG) + staged } } } @@ -266,6 +259,7 @@ fn content_spans( emphasis: Option<(Color, Color)>, word_spans: &[WordSpan], is_word_pair: bool, + theme: &Theme, ) -> Vec> { let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); if let Some((subtle_bg, strong_bg)) = emphasis { @@ -280,7 +274,7 @@ fn content_spans( } } - let segments = compose_segments(text.len(), &bg_spans, hl); + let segments = compose_segments(text.len(), &bg_spans, hl, theme); let mut spans = Vec::with_capacity(segments.len().max(1)); if segments.is_empty() && !text.is_empty() { spans.push(TSpan::styled( @@ -310,6 +304,7 @@ fn build_pane_line( mode: AttributionMode, gutter_w: usize, content_w: usize, + theme: &Theme, ) -> Line<'static> { match row { Row::Filler => { @@ -331,11 +326,18 @@ fn build_pane_line( let mut spans = vec![TSpan::styled(gutter, Style::default().fg(FG_GUTTER))]; let emphasis = match kind { - CellKind::Del => Some(del_bg_pair(mode, n as u32)), - CellKind::Add => Some(add_bg_pair(mode, n as u32)), + CellKind::Del => Some(del_bg_pair(mode, n as u32, theme)), + CellKind::Add => Some(add_bg_pair(mode, n as u32, theme)), CellKind::Context | CellKind::Filler => None, }; - spans.extend(content_spans(text, hl, emphasis, word_spans, is_word_pair)); + spans.extend(content_spans( + text, + hl, + emphasis, + word_spans, + is_word_pair, + theme, + )); Line::from(spans) } } @@ -344,8 +346,10 @@ fn build_pane_line( /// 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`]/ -/// [`crate::keymap::help_sections`]), never a hardcoded key string. -pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap) { +/// [`crate::keymap::help_sections`]), never a hardcoded key string. `theme` is the resolved +/// (CS4: always dark) on-tint palette — see [`crate::theme`]; the diff body, syntax foreground, +/// and cursor/selection washes all resolve their colors against it at paint time. +pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Theme) { let area = frame.area(); let vlayout = Layout::default() .direction(Direction::Vertical) @@ -375,16 +379,16 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap) { let outline_area = hlayout[0]; let div_area = hlayout[1]; let diff_area = hlayout[2]; - render_outline(frame, app, outline_area); + render_outline(frame, app, outline_area, theme); for y in div_area.y..div_area.y + div_area.height { frame .buffer_mut() .set_string(div_area.x, y, "│", Style::default().fg(FG_DIM)); } - render_body(frame, app, diff_area); + render_body(frame, app, diff_area, theme); } else { // Closed: the diff takes the full body width — the exact M4 look (locked design). - render_body(frame, app, body_area); + render_body(frame, app, body_area, theme); } if app.help_visible { @@ -456,10 +460,10 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// indent, a one-character staged-ness glyph (blank for a committed changeset's files — see /// [`crate::outline::StagedStatus`]'s doc comment for why no special-casing is needed here), and /// the path. The cursor row (the outline's OWN cursor — a separate coordinate space from the -/// diff's [`App::cursor`]) gets [`BG_CURSOR`] while the outline has focus, or the dimmer -/// [`BG_OUTLINE_CURSOR_UNFOCUSED`] while it's merely open (so the remembered position stays +/// diff's [`App::cursor`]) gets the theme's cursor tint while the outline has focus, or the dimmer +/// [`Theme::outline_cursor_unfocused_bg`] while it's merely open (so the remembered position stays /// legible even after focus returns to the diff). -fn render_outline(frame: &mut Frame, app: &App, area: Rect) { +fn render_outline(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) { let items = app.outline_items(); let cursor = app.outline_cursor(); let focused = app.outline_focused(); @@ -483,9 +487,9 @@ fn render_outline(frame: &mut Frame, app: &App, area: Rect) { let is_cursor = item_idx == cursor; let line = build_outline_line(item); let line = if is_cursor && focused { - apply_cursor_row(line, area.width) + apply_cursor_row(line, area.width, theme) } else if is_cursor { - apply_row_tint(line, area.width, BG_OUTLINE_CURSOR_UNFOCUSED) + apply_row_tint(line, area.width, theme.outline_cursor_unfocused_bg) } else { line }; @@ -685,21 +689,22 @@ fn render_gap_row( skipped: usize, is_cursor: bool, is_selected: bool, + theme: &Theme, ) { let msg = format!("··· {skipped} unchanged lines ···"); let line = Line::from(TSpan::styled(msg, Style::default().fg(FG_DIM))); // Cursor wins over selection on the same row. let line = if is_cursor { - apply_cursor_row(line, area.width) + apply_cursor_row(line, area.width, theme) } else if is_selected { - apply_selection_row(line, area.width) + apply_selection_row(line, area.width, theme) } else { line }; buf.set_line(area.x, y, &line, area.width); } -fn render_body(frame: &mut Frame, app: &mut App, area: Rect) { +fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Theme) { if app.files().is_empty() { frame.render_widget(Paragraph::new("(no changes)"), area); return; @@ -724,15 +729,15 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect) { // The single pane is the focused one, so it shows any active selection. let selection = app.selection_range(); match app.layout { - AppLayout::Sbs => { - render_pane_sbs(frame, app, area, idx, role, scroll, cursor, selection) - } - AppLayout::Inline => { - render_pane_inline(frame, app, area, idx, role, scroll, cursor, selection) - } + AppLayout::Sbs => render_pane_sbs( + frame, app, area, idx, role, scroll, cursor, selection, theme, + ), + AppLayout::Inline => render_pane_inline( + frame, app, area, idx, role, scroll, cursor, selection, theme, + ), } } - EffectiveZoom::Split => render_body_split(frame, app, area, idx), + EffectiveZoom::Split => render_body_split(frame, app, area, idx, theme), } } @@ -741,7 +746,7 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect) { /// the cursor highlight draws only in the focused pane. The body area splits caption(1) + /// 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) { +fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, theme: &Theme) { // 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 { @@ -750,12 +755,12 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { let (scroll, cursor) = app.pane_render_state(role); let selection = app.selection_range(); match app.layout { - AppLayout::Sbs => { - render_pane_sbs(frame, app, area, idx, role, scroll, cursor, selection) - } - AppLayout::Inline => { - render_pane_inline(frame, app, area, idx, role, scroll, cursor, selection) - } + AppLayout::Sbs => render_pane_sbs( + frame, app, area, idx, role, scroll, cursor, selection, theme, + ), + AppLayout::Inline => render_pane_inline( + frame, app, area, idx, role, scroll, cursor, selection, theme, + ), } return; } @@ -802,6 +807,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { u_scroll, u_cursor, u_selection, + theme, ); render_pane_sbs( frame, @@ -812,6 +818,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { s_scroll, s_cursor, s_selection, + theme, ); } AppLayout::Inline => { @@ -824,6 +831,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { u_scroll, u_cursor, u_selection, + theme, ); render_pane_inline( frame, @@ -834,6 +842,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { s_scroll, s_cursor, s_selection, + theme, ); } } @@ -860,6 +869,7 @@ fn render_pane_sbs( scroll: usize, cursor: Option, selection: Option<(usize, usize)>, + theme: &Theme, ) { let left_w = area.width.saturating_sub(1) / 2; let right_w = area.width.saturating_sub(1).saturating_sub(left_w); @@ -927,6 +937,7 @@ fn render_pane_sbs( *skipped, is_cursor, is_selected, + theme, ); } DisplayRow::Row(row) => { @@ -947,6 +958,7 @@ fn render_pane_sbs( mode, old_gutter_w, old_area.width as usize, + theme, ); let new_line = build_pane_line( view, @@ -958,17 +970,18 @@ fn render_pane_sbs( mode, new_gutter_w, new_area.width as usize, + theme, ); - // Cursor wins over selection on the same row (see [`BG_SELECTION`]). + // Cursor wins over selection on the same row (see [`Theme::selection_bg`]). let (old_line, new_line) = if is_cursor { ( - apply_cursor_row(old_line, old_area.width), - apply_cursor_row(new_line, new_area.width), + apply_cursor_row(old_line, old_area.width, theme), + apply_cursor_row(new_line, new_area.width, theme), ) } else if is_selected { ( - apply_selection_row(old_line, old_area.width), - apply_selection_row(new_line, new_area.width), + apply_selection_row(old_line, old_area.width, theme), + apply_selection_row(new_line, new_area.width, theme), ) } else { (old_line, new_line) @@ -987,7 +1000,7 @@ fn render_pane_sbs( div_area.x, y, "│", - Style::default().fg(FG_DIM).bg(BG_CURSOR), + Style::default().fg(FG_DIM).bg(theme.cursor_bg), ); } } @@ -1018,6 +1031,7 @@ fn build_inline_line( mode: AttributionMode, old_gutter_w: usize, new_gutter_w: usize, + theme: &Theme, ) -> Line<'static> { let (old_opt, new_opt, text, hl, kind) = match *row { InlineRow::Context { old, new } => ( @@ -1057,11 +1071,18 @@ 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)), - CellKind::Add => new_opt.map(|n| add_bg_pair(mode, n as u32)), + 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::Context | CellKind::Filler => None, }; - spans.extend(content_spans(text, hl, emphasis, word_spans, is_word_pair)); + spans.extend(content_spans( + text, + hl, + emphasis, + word_spans, + is_word_pair, + theme, + )); Line::from(spans) } @@ -1078,6 +1099,7 @@ fn render_pane_inline( scroll: usize, cursor: Option, selection: Option<(usize, usize)>, + theme: &Theme, ) { let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), area); @@ -1118,6 +1140,7 @@ fn render_pane_inline( *skipped, is_cursor, is_selected, + theme, ); } row => { @@ -1131,13 +1154,20 @@ fn render_pane_inline( InlineRow::Add { .. } => &new_spans, _ => &[], }; - let line = - build_inline_line(view, row, word_spans, mode, old_gutter_w, new_gutter_w); - // Cursor wins over selection on the same row (see [`BG_SELECTION`]). + let line = build_inline_line( + view, + row, + word_spans, + mode, + old_gutter_w, + new_gutter_w, + theme, + ); + // Cursor wins over selection on the same row (see [`Theme::selection_bg`]). let line = if is_cursor { - apply_cursor_row(line, area.width) + apply_cursor_row(line, area.width, theme) } else if is_selected { - apply_selection_row(line, area.width) + apply_selection_row(line, area.width, theme) } else { line }; @@ -1155,23 +1185,24 @@ mod tests { use git_workon_fixture::prelude::*; - use super::{ - render, BG_ADD_STAGED_STRONG, BG_ADD_STAGED_SUBTLE, BG_ADD_STRONG, BG_ADD_SUBTLE, - BG_DEL_STAGED_STRONG, BG_DEL_STAGED_SUBTLE, BG_DEL_STRONG, BG_DEL_SUBTLE, - }; + use super::render; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; use crate::app::App; use crate::keymap::Keymap; + use crate::theme::Theme; - /// Render one frame against the default (unrebound) keymap — the vast majority of `render.rs` - /// tests don't care about keybindings at all. Tests that DO (the footer/overlay content tests) - /// build their own [`Keymap`] and call [`render`] directly instead. + /// 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 + /// that DO care about bindings (the footer/overlay content tests) build their own [`Keymap`] + /// and call [`render`] directly instead. Color assertions resolve through [`Theme::dark`], so + /// they pin the exact dark values the refactor must preserve (ADR-029's pixel-identity gate). fn render_once(app: &mut App, width: u16, height: u16) -> 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)).unwrap(); + let theme = Theme::dark(); + terminal.draw(|f| render(f, app, &keymap, &theme)).unwrap(); terminal.backend().buffer().clone() } @@ -1484,7 +1515,7 @@ mod tests { ); assert_eq!( buf.cell((divider_x, cursor_y)).unwrap().style().bg, - Some(super::BG_CURSOR), + Some(Theme::dark().cursor_bg), "expected the cursor row's DIVIDER cell to carry the cursor background, not the \ default — otherwise the highlight has a seam through the middle" ); @@ -1548,7 +1579,7 @@ mod tests { // has no bg) — i.e. the raw tint, since blend_bg(None, tint) == tint. assert_eq!( bg(1, sel_y), - Some(super::BG_SELECTION), + Some(Theme::dark().selection_bg), "a selected plain-context row shows the raw selection tint" ); } @@ -1730,8 +1761,9 @@ mod tests { .style() .bg; - let dim_dels = [Some(BG_DEL_STAGED_SUBTLE), Some(BG_DEL_STAGED_STRONG)]; - let bright_dels = [Some(BG_DEL_SUBTLE), Some(BG_DEL_STRONG)]; + let t = Theme::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)]; assert!( dim_dels.contains(&staged_del_bg), "expected the staged row's Del side to use the dim pair, got {staged_del_bg:?}" @@ -1759,8 +1791,8 @@ mod tests { .style() .bg; - let dim_adds = [Some(BG_ADD_STAGED_SUBTLE), Some(BG_ADD_STAGED_STRONG)]; - let bright_adds = [Some(BG_ADD_SUBTLE), Some(BG_ADD_STRONG)]; + let dim_adds = [Some(t.add_staged_subtle), Some(t.add_staged_strong)]; + let bright_adds = [Some(t.add_subtle), Some(t.add_strong)]; assert!( dim_adds.contains(&staged_add_bg), "expected the staged row's Add side to use the dim pair, got {staged_add_bg:?}" @@ -1839,7 +1871,10 @@ mod tests { let backend = TestBackend::new(80, 10); let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|f| render(f, &mut app, &keymap)).unwrap(); + let theme = Theme::dark(); + terminal + .draw(|f| render(f, &mut app, &keymap, &theme)) + .unwrap(); let buf = terminal.backend().buffer().clone(); let footer_y = buf.area.height - 1; @@ -2123,8 +2158,9 @@ mod tests { let new_content_x = left_w + 1 + 4; // divider + gutter width 3 + 1 space let add_bg = buf.cell((new_content_x, row_y)).unwrap().style().bg; - let bright_adds = [Some(BG_ADD_SUBTLE), Some(BG_ADD_STRONG)]; - let dim_adds = [Some(BG_ADD_STAGED_SUBTLE), Some(BG_ADD_STAGED_STRONG)]; + let t = Theme::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)]; assert!( bright_adds.contains(&add_bg), "expected a committed changeset's Add cell to render the plain (bright) pair, \ @@ -2268,8 +2304,8 @@ mod tests { let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); assert_eq!( buf.cell((2, cursor_y)).unwrap().style().bg, - Some(super::BG_CURSOR), - "expected the outline's cursor row to carry BG_CURSOR while focused" + Some(Theme::dark().cursor_bg), + "expected the outline's cursor row to carry the cursor tint while focused" ); } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs new file mode 100644 index 0000000..25dc462 --- /dev/null +++ b/git-workon-review/src/theme.rs @@ -0,0 +1,211 @@ +//! The base16 color-scheme primitive and the colors the renderer resolves against it (ADR-029). +//! +//! This is the theming *primitive* — the resolved palette a frame is painted with — distinct from +//! [`crate::config::Theme`], which is the git-config *selection* (`auto`/`dark`/`light`). CS4 is +//! dark-only and behavior-preserving: [`Theme::dark`] reproduces M3–M5's hardcoded colors exactly. +//! CS5 adds a light instance and wires [`crate::config::Theme`] to pick between them; CS6 adds the +//! terminal-derivation probe for `auto`. +//! +//! ## Hybrid boundary (ADR-029) +//! Colors that sit ON a tinted background — the diff add/del gradient, its staged variants, the +//! cursor/selection washes, and syntax foreground — are theme-controlled base16 truecolor and live +//! here. Chrome that is NOT on a tint (gutter, dividers, footer, dim labels, status markers) stays +//! ANSI-named / const in [`crate::render`] so it self-adapts to the terminal palette and is +//! probe-independent. This module deliberately holds only the on-tint half. + +use ratatui::style::Color; + +/// A 16-slot base16 palette: `base00`–`base07` are the monochrome ramp (background → foreground), +/// `base08`–`base0F` the accents. Slot roles follow the base16 styling spec (base08 red, base0B +/// green, base0E keyword, …). Indexed 0–15 by slot number. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Base16 { + pub slots: [Color; 16], +} + +impl Base16 { + /// base16-eighties.dark (Chris Kempson) — the scheme M3–M5's syntax accents were already drawn + /// from (`highlight.rs`'s `C_*` consts ARE these slots; see ADR-029). Reproduced here in full + /// so `Theme::dark` is a faithful re-expression of the shipped dark colors. + const EIGHTIES_DARK: Base16 = Base16 { + slots: [ + Color::Rgb(0x2d, 0x2d, 0x2d), // base00 background + Color::Rgb(0x39, 0x39, 0x39), // base01 + Color::Rgb(0x51, 0x51, 0x51), // base02 + Color::Rgb(0x74, 0x73, 0x69), // base03 comments + Color::Rgb(0xa0, 0x9f, 0x93), // base04 + Color::Rgb(0xd3, 0xd0, 0xc8), // base05 foreground + Color::Rgb(0xe8, 0xe6, 0xdf), // base06 + Color::Rgb(0xf2, 0xf0, 0xec), // base07 + Color::Rgb(0xf2, 0x77, 0x7a), // base08 red / diff deleted + Color::Rgb(0xf9, 0x91, 0x57), // base09 orange + Color::Rgb(0xff, 0xcc, 0x66), // base0A yellow + Color::Rgb(0x99, 0xcc, 0x99), // base0B green / diff inserted + Color::Rgb(0x66, 0xcc, 0xcc), // base0C cyan + Color::Rgb(0x66, 0x99, 0xcc), // base0D blue + Color::Rgb(0xcc, 0x99, 0xcc), // base0E purple / keyword + Color::Rgb(0xd2, 0x7b, 0x53), // base0F brown + ], + }; + + fn slot(&self, i: usize) -> Color { + self.slots[i] + } +} + +/// Per-capture syntax template: each entry is the base16 slot index that the parallel +/// [`crate::highlight::HIGHLIGHT_NAMES`] capture maps to, per the base16 role conventions +/// (ADR-029). Theme-invariant — every scheme applies this same template to its own slots — so it +/// lives with the primitive, not on any one [`Theme`]. A theme switch re-colors by re-rendering: +/// the tree-sitter pass records only the capture index (see [`crate::highlight::FgSpan`]), and the +/// color is resolved here at paint time. +const SYNTAX_SLOTS: [usize; 28] = [ + 9, // attribute → base09 orange + 3, // comment → base03 + 9, // constant → base09 + 9, // constant.builtin → base09 + 10, // constructor → base0A yellow + 5, // embedded → base05 fg + 12, // escape → base0C cyan + 13, // function → base0D blue + 13, // function.builtin → base0D + 13, // function.macro → base0D + 13, // function.method → base0D + 14, // keyword → base0E purple + 8, // label → base08 red + 9, // number → base09 + 5, // operator → base05 + 12, // property → base0C + 5, // punctuation → base05 + 5, // punctuation.bracket → base05 + 5, // punctuation.delimiter→ base05 + 12, // punctuation.special → base0C + 11, // string → base0B green + 12, // string.special → base0C + 8, // tag → base08 + 10, // type → base0A + 10, // type.builtin → base0A + 5, // variable → base05 + 8, // variable.builtin → base08 + 5, // variable.parameter → base05 +]; + +/// The number of entries in the per-capture syntax template — must equal +/// [`crate::highlight::HIGHLIGHT_NAMES`]'s length (asserted in `highlight`'s tests). Exposed so +/// that invariant can be checked without making [`SYNTAX_SLOTS`] itself public. +pub fn syntax_slot_count() -> usize { + SYNTAX_SLOTS.len() +} + +/// The resolved on-tint palette a frame is painted with (ADR-029's theme-controlled half). +/// +/// Syntax foreground is looked up per capture index via [`Theme::syntax`]; the diff-background +/// gradient, its staged variants, and the cursor/selection/outline washes are read directly. All +/// values in [`Theme::dark`] reproduce the M3–M5 hardcoded colors exactly (CS4 is a +/// behavior-preserving refactor). +pub struct Theme { + /// 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 [`Theme::del_subtle`]). + pub add_subtle: Color, + pub add_strong: 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, + /// Dim Add pair — green-tinted counterpart of the staged Del pair. + pub add_staged_subtle: Color, + pub add_staged_strong: 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 + /// [`Theme::cursor_bg`]. + pub selection_bg: Color, + /// Cursor wash for the outline pane while OPEN but NOT focused — dimmer than [`Theme::cursor_bg`]. + pub outline_cursor_unfocused_bg: Color, +} + +impl Theme { + /// The curated dark scheme: base16-eighties.dark accents + the M3–M5 hand-tuned diff/cursor + /// tints, reproduced byte-for-byte (the pixel-identity gate — see the module doc and ADR-029). + /// + /// The diff-bg tints are held explicit rather than derived: a clean base08/base0B → base00 + /// blend cannot reproduce these particular hand-tuned constants (their green/blue channels sit + /// *below* base00, so no convex blend toward base00 reaches them). ADR-029's derivation is + /// therefore deferred to CS5, where the light scheme defines its own tints; dark keeps the + /// shipped values verbatim. + pub fn dark() -> Self { + let base = Base16::EIGHTIES_DARK; + Theme { + 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), + cursor_bg: Color::Rgb(45, 50, 90), + selection_bg: Color::Rgb(30, 66, 66), + outline_cursor_unfocused_bg: Color::Rgb(35, 38, 55), + } + } + + /// The syntax foreground for a capture index (position in + /// [`crate::highlight::HIGHLIGHT_NAMES`]). This is the render-time resolution the whole + /// mechanism turns on: [`crate::highlight::FgSpan`] carries the index, the renderer resolves + /// the color here. Panics on an out-of-range index, exactly as the former direct + /// `HIGHLIGHT_COLORS[idx]` lookup did — the index always comes from the bound capture space. + pub fn syntax(&self, capture: usize) -> Color { + self.syntax[capture] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::highlight::{capture_index, HIGHLIGHT_NAMES}; + + #[test] + fn syntax_template_is_parallel_with_the_capture_names() { + assert_eq!(SYNTAX_SLOTS.len(), HIGHLIGHT_NAMES.len()); + } + + #[test] + fn dark_syntax_resolves_representative_captures_to_the_historical_colors() { + let theme = Theme::dark(); + let color = |name: &str| theme.syntax(capture_index(name).unwrap()); + // The exact C_* consts highlight.rs shipped in M3 (base16-eighties.dark accents). + assert_eq!(color("keyword"), Color::Rgb(0xcc, 0x99, 0xcc)); // C_PURPLE / base0E + assert_eq!(color("string"), Color::Rgb(0x99, 0xcc, 0x99)); // C_GREEN / base0B + assert_eq!(color("comment"), Color::Rgb(0x74, 0x73, 0x69)); // C_COMMENT / base03 + assert_eq!(color("function"), Color::Rgb(0x66, 0x99, 0xcc)); // C_BLUE / base0D + assert_eq!(color("number"), Color::Rgb(0xf9, 0x91, 0x57)); // C_ORANGE / base09 + assert_eq!(color("variable"), Color::Rgb(0xd3, 0xd0, 0xc8)); // C_FG / base05 + } + + #[test] + fn dark_diff_tints_match_the_historical_constants() { + // The pixel-identity gate: `Theme::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 = Theme::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.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)); + } +} diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index af1554b..6cc50b3 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -24,6 +24,7 @@ use ratatui::Terminal; use workon_review::app::App; use workon_review::keymap::{Command, Dispatch, KeyPress, Keymap}; use workon_review::render; +use workon_review::theme::Theme; /// One event the review loop reacts to. `Tick` is now also the index-watcher's poll beat (see the /// module doc's note on locked decision #4) — `next_event`'s mapping and this enum otherwise stay @@ -301,7 +302,7 @@ fn install_panic_hook() { /// Run the review TUI's terminal lifecycle and main loop against `app`. Callers must have /// already loaded the initial file (`app.open_current()`) before calling this. -pub fn run(app: &mut App, keymap: &Keymap) -> io::Result<()> { +pub fn run(app: &mut App, keymap: &Keymap, theme: &Theme) -> io::Result<()> { install_panic_hook(); enable_raw_mode()?; let mut out = terminal_writer(); @@ -309,7 +310,7 @@ pub fn run(app: &mut App, keymap: &Keymap) -> io::Result<()> { let backend = CrosstermBackend::new(out); let mut terminal = Terminal::new(backend)?; - let result = event_loop(&mut terminal, app, keymap); + let result = event_loop(&mut terminal, app, keymap, theme); disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; @@ -322,12 +323,13 @@ fn event_loop( terminal: &mut Terminal>, app: &mut App, keymap: &Keymap, + theme: &Theme, ) -> io::Result<()> { let mut pending: Vec = Vec::new(); let mut quit = false; loop { - terminal.draw(|f| render::render(f, app, keymap))?; + terminal.draw(|f| render::render(f, app, keymap, theme))?; if quit { return Ok(()); From 858041334be00ea257d82a53ee491b5e81d2939c Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 01:34:16 -0400 Subject: [PATCH 054/203] feat(review): add light theme and theme selection --- docs/adr/029-review-theming-base16-hybrid.md | 12 +- git-workon-review/src/highlight.rs | 4 +- git-workon-review/src/main.rs | 15 +- git-workon-review/src/render.rs | 60 ++--- git-workon-review/src/theme.rs | 233 +++++++++++++++++-- git-workon-review/src/tui.rs | 6 +- 6 files changed, 266 insertions(+), 64 deletions(-) diff --git a/docs/adr/029-review-theming-base16-hybrid.md b/docs/adr/029-review-theming-base16-hybrid.md index 3973c84..5c57e8d 100644 --- a/docs/adr/029-review-theming-base16-hybrid.md +++ b/docs/adr/029-review-theming-base16-hybrid.md @@ -34,7 +34,7 @@ is spec-conformant. are **probe-independent** (work even when terminal-derivation fails). Half already are ANSI-named today. -**Primitive — the theme is a base16 scheme.** A `Theme` holds the 16 slots +**Primitive — the theme is a base16 scheme.** A `Palette` holds the 16 slots (base00–07 mono ramp + base08–0F accents). Syntax uses the accents via the existing capture→slot template. @@ -44,7 +44,7 @@ derivation is luminance-dependent, not a single "blend toward base00" (corrected - **Dark (base00 dark):** the shipped M3–M5 tints are more saturated/darker than *any* convex blend of an accent toward a dark base00 can produce (their green/blue channels sit *below* base00's). A blend toward a dark base00 also yields muddy mid-tones, not punchy washes. So - the **dark tints are held explicit** in `Theme::dark()` (byte-identical to M3–M5, per the + the **dark tints are held explicit** in `Palette::dark()` (byte-identical to M3–M5, per the pixel-identity gate). Deriving them would require scaling the accent toward *black* plus a desaturation step, not a base00 blend — not worth reverse-engineering the hand-tuned values. - **Light (base00 light) and terminal-derived:** blending an accent toward a *light* base00 @@ -60,7 +60,7 @@ stays authored. `config.configure()` and is theme-invariant. - `FgSpan` carries the **capture index** (semantic role), not a resolved `Color`. The highlight phase (`highlight.rs:283`) records the index instead of looking up a color. -- Render resolves `index → Color` against the active `Theme` (`theme.slot[idx]`), in the +- Render resolves `index → Color` against the active `Palette` (`palette.slot[idx]`), in the same place it resolves diff tints and cursor/selection. One theme-application site; syntax and background contrast are reasoned about together. - Consequence: the expensive tree-sitter pass is theme-free and cacheable — a theme switch @@ -93,7 +93,7 @@ stays authored. - Light/dark ships as curated base16 schemes now; **terminal-derivation is first-class from the start**, not deferred. `auto` never has to change meaning later. -- Because color resolves late as `theme.slot[idx]`, the slot *source* is pluggable — a future +- Because color resolves late as `palette.slot[idx]`, the slot *source* is pluggable — a future user-supplied base16 scheme (`theme = ` / a scheme file, the deferred "user-configurable colors" tier) is additive, no renderer change. - The OSC probe is the single most terminal-fragile component; its blast radius is contained @@ -101,9 +101,9 @@ stays authored. never a hang or a broken palette. - Adding a syntax capture = adding it to `HIGHLIGHT_NAMES` + the capture→slot template; it is automatically themed by every scheme. -- `render.rs` and `highlight.rs` both change: the `const` palette becomes a `Theme` threaded +- `render.rs` and `highlight.rs` both change: the `const` palette becomes a `Palette` threaded to render; `FgSpan` loses its `Color` field in favor of a capture index. Existing render - tests that assert concrete colors must resolve through a fixed test `Theme`. + tests that assert concrete colors must resolve through a fixed test `Palette`. ## References diff --git a/git-workon-review/src/highlight.rs b/git-workon-review/src/highlight.rs index 715bbd6..edbd4bb 100644 --- a/git-workon-review/src/highlight.rs +++ b/git-workon-review/src/highlight.rs @@ -15,13 +15,13 @@ pub const MAX_HIGHLIGHT_LINES: usize = 20_000; /// Foreground syntax span for a single line: a byte range and the semantic *capture index* — /// the position in [`HIGHLIGHT_NAMES`] of the capture that covers it. The color is resolved at -/// render time against the active [`crate::theme::Theme`] (ADR-029), NOT baked in here: the +/// render time against the active [`crate::theme::Palette`] (ADR-029), NOT baked in here: the /// tree-sitter pass is theme-free and cacheable, and a theme switch recolors by re-rendering. #[derive(Debug, Clone)] pub struct FgSpan { pub start: usize, pub end: usize, - /// Index into [`HIGHLIGHT_NAMES`]; resolve via [`crate::theme::Theme::syntax`]. + /// Index into [`HIGHLIGHT_NAMES`]; resolve via [`crate::theme::Palette::syntax`]. pub capture: usize, } diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 02f013f..f96a2a7 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -8,7 +8,7 @@ use workon_review::acquire::{diff_changeset, resolve_changesets}; use workon_review::app::{App, ChangesetView, Severity}; use workon_review::config::ReviewConfig; use workon_review::keymap::Keymap; -use workon_review::theme::Theme; +use workon_review::theme::Palette; /// A TUI for reviewing changesets #[derive(Debug, Parser)] @@ -55,6 +55,14 @@ fn main() -> Result<()> { 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); `Palette::for_theme` handles the + // parsed-selection cases (including `Auto`'s CS6-deferred fallback to dark). + let theme = ReviewConfig::new(&repo) + .theme() + .map(Palette::for_theme) + .unwrap_or_else(|_| Palette::dark()); + // 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 @@ -82,11 +90,6 @@ fn main() -> Result<()> { app.notify(warnings.join("; "), Severity::Error); } - // CS4 is dark-only and unconditional — a pure refactor with no user-visible change. CS5 wires - // `ReviewConfig::theme()` (config `Theme::{Auto,Dark,Light}`) to pick the palette here; CS6 - // adds the terminal-derivation probe for `auto`. - let theme = Theme::dark(); - tui::run(&mut app, &keymap, &theme).into_diagnostic()?; Ok(()) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index df6e9fb..7b16962 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -20,17 +20,17 @@ use crate::highlight::FgSpan; use crate::keymap::{footer_hint, help_sections, Keymap}; use crate::model::FileStatus; use crate::outline::OutlineItem; -use crate::theme::Theme; +use crate::theme::Palette; use crate::wordiff::Span as WordSpan; // The on-tint colors (diff add/del gradient + staged variants, cursor/selection washes, and syntax -// foreground) now come from a [`Theme`] threaded through render (ADR-029). The chrome colors below +// foreground) now come from a [`Palette`] threaded through render (ADR-029). The chrome colors below // stay ANSI-named / const here: they never sit on a tint, so they inherit the terminal palette and // self-adapt light/dark, independent of the theme (the hybrid boundary — see the `theme` module). /// Default foreground for diff text that carries no syntax highlight — an ANSI gray that inherits /// the terminal palette (chrome, not on-tint). Syntax-highlighted text resolves its fg from the -/// [`Theme`] instead (see [`compose_segments`]). +/// [`Palette`] instead (see [`compose_segments`]). const FG_DEFAULT: Color = Color::Gray; const FG_DIM: Color = Color::DarkGray; /// Footer text color for an [`Severity::Error`] [`Notice`] — a clearly-red tone that reads on @@ -85,12 +85,12 @@ fn apply_row_tint(mut line: Line<'static>, width: u16, tint: Color) -> Line<'sta } /// Wash the cursor row with the theme's cursor tint. -fn apply_cursor_row(line: Line<'static>, width: u16, theme: &Theme) -> Line<'static> { +fn apply_cursor_row(line: Line<'static>, width: u16, theme: &Palette) -> Line<'static> { apply_row_tint(line, width, theme.cursor_bg) } /// Wash a selected (line-selection) row with the theme's selection tint. -fn apply_selection_row(line: Line<'static>, width: u16, theme: &Theme) -> Line<'static> { +fn apply_selection_row(line: Line<'static>, width: u16, theme: &Palette) -> Line<'static> { apply_row_tint(line, width, theme.selection_bg) } @@ -110,7 +110,7 @@ fn compose_segments( len: usize, bg_spans: &[(usize, usize, Color)], fg_spans: Option<&Vec>, - theme: &Theme, + theme: &Palette, ) -> Vec { let mut boundaries: Vec = vec![0, len]; for (s, e, _) in bg_spans { @@ -203,7 +203,7 @@ 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: &Theme) -> (Color, Color) { +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); match mode { @@ -221,7 +221,7 @@ fn del_bg_pair(mode: AttributionMode, old_lnum: u32, theme: &Theme) -> (Color, C /// 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: &Theme) -> (Color, Color) { +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); match mode { @@ -259,7 +259,7 @@ fn content_spans( emphasis: Option<(Color, Color)>, word_spans: &[WordSpan], is_word_pair: bool, - theme: &Theme, + theme: &Palette, ) -> Vec> { let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); if let Some((subtle_bg, strong_bg)) = emphasis { @@ -304,7 +304,7 @@ fn build_pane_line( mode: AttributionMode, gutter_w: usize, content_w: usize, - theme: &Theme, + theme: &Palette, ) -> Line<'static> { match row { Row::Filler => { @@ -349,7 +349,7 @@ fn build_pane_line( /// [`crate::keymap::help_sections`]), never a hardcoded key string. `theme` is the resolved /// (CS4: always dark) on-tint palette — see [`crate::theme`]; the diff body, syntax foreground, /// and cursor/selection washes all resolve their colors against it at paint time. -pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Theme) { +pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette) { let area = frame.area(); let vlayout = Layout::default() .direction(Direction::Vertical) @@ -461,9 +461,9 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// [`crate::outline::StagedStatus`]'s doc comment for why no special-casing is needed here), and /// the path. 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 -/// [`Theme::outline_cursor_unfocused_bg`] while it's merely open (so the remembered position stays +/// [`Palette::outline_cursor_unfocused_bg`] while it's merely open (so the remembered position stays /// legible even after focus returns to the diff). -fn render_outline(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) { +fn render_outline(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { let items = app.outline_items(); let cursor = app.outline_cursor(); let focused = app.outline_focused(); @@ -689,7 +689,7 @@ fn render_gap_row( skipped: usize, is_cursor: bool, is_selected: bool, - theme: &Theme, + theme: &Palette, ) { let msg = format!("··· {skipped} unchanged lines ···"); let line = Line::from(TSpan::styled(msg, Style::default().fg(FG_DIM))); @@ -704,7 +704,7 @@ fn render_gap_row( buf.set_line(area.x, y, &line, area.width); } -fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Theme) { +fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { if app.files().is_empty() { frame.render_widget(Paragraph::new("(no changes)"), area); return; @@ -746,7 +746,7 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Theme) { /// the cursor highlight draws only in the focused pane. The body area splits caption(1) + /// 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: &Theme) { +fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, theme: &Palette) { // 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 { @@ -869,7 +869,7 @@ fn render_pane_sbs( scroll: usize, cursor: Option, selection: Option<(usize, usize)>, - theme: &Theme, + theme: &Palette, ) { let left_w = area.width.saturating_sub(1) / 2; let right_w = area.width.saturating_sub(1).saturating_sub(left_w); @@ -972,7 +972,7 @@ fn render_pane_sbs( new_area.width as usize, theme, ); - // Cursor wins over selection on the same row (see [`Theme::selection_bg`]). + // 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), @@ -1031,7 +1031,7 @@ fn build_inline_line( mode: AttributionMode, old_gutter_w: usize, new_gutter_w: usize, - theme: &Theme, + theme: &Palette, ) -> Line<'static> { let (old_opt, new_opt, text, hl, kind) = match *row { InlineRow::Context { old, new } => ( @@ -1099,7 +1099,7 @@ fn render_pane_inline( scroll: usize, cursor: Option, selection: Option<(usize, usize)>, - theme: &Theme, + theme: &Palette, ) { let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), area); @@ -1163,7 +1163,7 @@ fn render_pane_inline( new_gutter_w, theme, ); - // Cursor wins over selection on the same row (see [`Theme::selection_bg`]). + // Cursor wins over selection on the same row (see [`Palette::selection_bg`]). let line = if is_cursor { apply_cursor_row(line, area.width, theme) } else if is_selected { @@ -1190,18 +1190,18 @@ mod tests { use crate::app::test_support::app_from_fixture; use crate::app::App; use crate::keymap::Keymap; - use crate::theme::Theme; + use crate::theme::Palette; /// 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 /// that DO care about bindings (the footer/overlay content tests) build their own [`Keymap`] - /// and call [`render`] directly instead. Color assertions resolve through [`Theme::dark`], so + /// and call [`render`] directly instead. Color assertions resolve through [`Palette::dark`], so /// they pin the exact dark values the refactor must preserve (ADR-029's pixel-identity gate). fn render_once(app: &mut App, width: u16, height: u16) -> Buffer { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).unwrap(); let keymap = Keymap::defaults(); - let theme = Theme::dark(); + let theme = Palette::dark(); terminal.draw(|f| render(f, app, &keymap, &theme)).unwrap(); terminal.backend().buffer().clone() } @@ -1515,7 +1515,7 @@ mod tests { ); assert_eq!( buf.cell((divider_x, cursor_y)).unwrap().style().bg, - Some(Theme::dark().cursor_bg), + Some(Palette::dark().cursor_bg), "expected the cursor row's DIVIDER cell to carry the cursor background, not the \ default — otherwise the highlight has a seam through the middle" ); @@ -1579,7 +1579,7 @@ mod tests { // has no bg) — i.e. the raw tint, since blend_bg(None, tint) == tint. assert_eq!( bg(1, sel_y), - Some(Theme::dark().selection_bg), + Some(Palette::dark().selection_bg), "a selected plain-context row shows the raw selection tint" ); } @@ -1761,7 +1761,7 @@ mod tests { .style() .bg; - let t = Theme::dark(); + 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)]; assert!( @@ -1871,7 +1871,7 @@ mod tests { let backend = TestBackend::new(80, 10); let mut terminal = Terminal::new(backend).unwrap(); - let theme = Theme::dark(); + let theme = Palette::dark(); terminal .draw(|f| render(f, &mut app, &keymap, &theme)) .unwrap(); @@ -2158,7 +2158,7 @@ mod tests { let new_content_x = left_w + 1 + 4; // divider + gutter width 3 + 1 space let add_bg = buf.cell((new_content_x, row_y)).unwrap().style().bg; - let t = Theme::dark(); + 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)]; assert!( @@ -2304,7 +2304,7 @@ mod tests { let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); assert_eq!( buf.cell((2, cursor_y)).unwrap().style().bg, - Some(Theme::dark().cursor_bg), + Some(Palette::dark().cursor_bg), "expected the outline's cursor row to carry the cursor tint while focused" ); } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 25dc462..5e6b554 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -1,9 +1,9 @@ //! The base16 color-scheme primitive and the colors the renderer resolves against it (ADR-029). //! //! This is the theming *primitive* — the resolved palette a frame is painted with — distinct from -//! [`crate::config::Theme`], which is the git-config *selection* (`auto`/`dark`/`light`). CS4 is -//! dark-only and behavior-preserving: [`Theme::dark`] reproduces M3–M5's hardcoded colors exactly. -//! CS5 adds a light instance and wires [`crate::config::Theme`] to pick between them; CS6 adds the +//! [`crate::config::Theme`], which is the git-config *selection* (`auto`/`dark`/`light`). CS4 was +//! dark-only and behavior-preserving: [`Palette::dark`] reproduces M3–M5's hardcoded colors exactly. +//! CS5 adds [`Palette::light`] and wires [`crate::config::Theme`] to pick between them; CS6 adds the //! terminal-derivation probe for `auto`. //! //! ## Hybrid boundary (ADR-029) @@ -26,7 +26,7 @@ pub struct Base16 { impl Base16 { /// base16-eighties.dark (Chris Kempson) — the scheme M3–M5's syntax accents were already drawn /// from (`highlight.rs`'s `C_*` consts ARE these slots; see ADR-029). Reproduced here in full - /// so `Theme::dark` is a faithful re-expression of the shipped dark colors. + /// so `Palette::dark` is a faithful re-expression of the shipped dark colors. const EIGHTIES_DARK: Base16 = Base16 { slots: [ Color::Rgb(0x2d, 0x2d, 0x2d), // base00 background @@ -48,15 +48,57 @@ impl Base16 { ], }; + /// base16-one-light (Daniel Pfeifer, http://github.com/purpleKarrot) — a published base16 + /// LIGHT scheme (tinted-theming/schemes, `base16/one-light.yaml`), pasted verbatim per + /// ADR-029 ("do NOT hand-invent accent colors"). base00 is near-white (the light background); + /// base07 is the darkest ramp step (high-contrast fg on a light bg — base16's ramp direction + /// is background→foreground, and "foreground" on a light scheme means dark). + const ONE_LIGHT: Base16 = Base16 { + slots: [ + Color::Rgb(0xfa, 0xfa, 0xfa), // base00 background + Color::Rgb(0xf0, 0xf0, 0xf1), // base01 + Color::Rgb(0xe5, 0xe5, 0xe6), // base02 + Color::Rgb(0xa0, 0xa1, 0xa7), // base03 comments + Color::Rgb(0x69, 0x6c, 0x77), // base04 + Color::Rgb(0x38, 0x3a, 0x42), // base05 foreground + Color::Rgb(0x20, 0x22, 0x27), // base06 + Color::Rgb(0x09, 0x0a, 0x0b), // base07 + Color::Rgb(0xca, 0x12, 0x43), // base08 red / diff deleted + Color::Rgb(0xd7, 0x5f, 0x00), // base09 orange + Color::Rgb(0xc1, 0x84, 0x01), // base0A yellow + Color::Rgb(0x50, 0xa1, 0x4f), // base0B green / diff inserted + Color::Rgb(0x01, 0x84, 0xbc), // base0C cyan + Color::Rgb(0x40, 0x78, 0xf2), // base0D blue + Color::Rgb(0xa6, 0x26, 0xa4), // base0E purple / keyword + Color::Rgb(0x98, 0x68, 0x01), // base0F brown + ], + }; + fn slot(&self, i: usize) -> Color { self.slots[i] } } +/// 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-029 +/// describes for a LIGHT base00: blending an accent toward a light background yields a pale, +/// correctly-hued wash (the dark scheme can't use this — see [`Palette::dark`]'s doc comment for +/// why dark tints are held explicit instead). Non-RGB colors pass through unblended. +fn tint_toward(color: Color, base: Color, ratio: f32) -> Color { + match (color, base) { + (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => { + let lerp = + |a: u8, b: u8| -> u8 { (a as f32 + (b as f32 - a as f32) * ratio).round() as u8 }; + Color::Rgb(lerp(r1, r2), lerp(g1, g2), lerp(b1, b2)) + } + _ => color, + } +} + /// Per-capture syntax template: each entry is the base16 slot index that the parallel /// [`crate::highlight::HIGHLIGHT_NAMES`] capture maps to, per the base16 role conventions -/// (ADR-029). Theme-invariant — every scheme applies this same template to its own slots — so it -/// lives with the primitive, not on any one [`Theme`]. A theme switch re-colors by re-rendering: +/// (ADR-029). Palette-invariant — every scheme applies this same template to its own slots — so it +/// lives with the primitive, not on any one [`Palette`]. A theme switch re-colors by re-rendering: /// the tree-sitter pass records only the capture index (see [`crate::highlight::FgSpan`]), and the /// color is resolved here at paint time. const SYNTAX_SLOTS: [usize; 28] = [ @@ -99,11 +141,11 @@ pub fn syntax_slot_count() -> usize { /// The resolved on-tint palette a frame is painted with (ADR-029's theme-controlled half). /// -/// Syntax foreground is looked up per capture index via [`Theme::syntax`]; the diff-background +/// 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 [`Theme::dark`] reproduce the M3–M5 hardcoded colors exactly (CS4 is a +/// values in [`Palette::dark`] reproduce the M3–M5 hardcoded colors exactly (CS4 is a /// behavior-preserving refactor). -pub struct Theme { +pub struct Palette { /// Per-capture syntax fg, indexed by the same capture index as /// [`crate::highlight::HIGHLIGHT_NAMES`] (see [`SYNTAX_SLOTS`]). syntax: Vec, @@ -111,7 +153,7 @@ pub struct Theme { /// 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 [`Theme::del_subtle`]). + /// Bright Add-cell background pair (counterpart of [`Palette::del_subtle`]). pub add_subtle: Color, pub add_strong: Color, /// Dim/desaturated Del pair for staged-ness attribution (locked decision #7) — a staged change @@ -125,13 +167,13 @@ pub struct Theme { /// 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 - /// [`Theme::cursor_bg`]. + /// [`Palette::cursor_bg`]. pub selection_bg: Color, - /// Cursor wash for the outline pane while OPEN but NOT focused — dimmer than [`Theme::cursor_bg`]. + /// Cursor wash for the outline pane while OPEN but NOT focused — dimmer than [`Palette::cursor_bg`]. pub outline_cursor_unfocused_bg: Color, } -impl Theme { +impl Palette { /// The curated dark scheme: base16-eighties.dark accents + the M3–M5 hand-tuned diff/cursor /// tints, reproduced byte-for-byte (the pixel-identity gate — see the module doc and ADR-029). /// @@ -142,7 +184,7 @@ impl Theme { /// shipped values verbatim. pub fn dark() -> Self { let base = Base16::EIGHTIES_DARK; - Theme { + 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), @@ -158,6 +200,50 @@ impl Theme { } } + /// The curated light scheme: base16-one-light accents (see [`Base16::ONE_LIGHT`]) with the + /// diff/cursor tints DERIVED via [`tint_toward`], per ADR-029's corrected primitive section — + /// 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 + /// 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 { + let base = Base16::ONE_LIGHT; + let base00 = base.slot(0); + let red = base.slot(8); // base08 + let green = base.slot(11); // base0B + 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 + // 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 CURSOR: f32 = 0.82; + const OUTLINE_CURSOR_UNFOCUSED: f32 = 0.90; + + 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), + 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), + } + } + /// The syntax foreground for a capture index (position in /// [`crate::highlight::HIGHLIGHT_NAMES`]). This is the render-time resolution the whole /// mechanism turns on: [`crate::highlight::FgSpan`] carries the index, the renderer resolves @@ -166,6 +252,18 @@ impl Theme { pub fn syntax(&self, capture: usize) -> Color { self.syntax[capture] } + + /// Resolve the on-tint palette for a `workon.review.theme` selection (ADR-029/CS5). `Auto` + /// falls back to dark for now — CS6 adds the terminal-derivation probe that gives `Auto` its + /// real meaning. A config-read error is the caller's concern (see `main.rs`): this function + /// only handles a successfully-parsed selection. + pub fn for_theme(theme: crate::config::Theme) -> Self { + match theme { + crate::config::Theme::Light => Self::light(), + crate::config::Theme::Dark => Self::dark(), + crate::config::Theme::Auto => Self::dark(), // CS6: terminal-derive + } + } } #[cfg(test)] @@ -180,7 +278,7 @@ mod tests { #[test] fn dark_syntax_resolves_representative_captures_to_the_historical_colors() { - let theme = Theme::dark(); + let theme = Palette::dark(); let color = |name: &str| theme.syntax(capture_index(name).unwrap()); // The exact C_* consts highlight.rs shipped in M3 (base16-eighties.dark accents). assert_eq!(color("keyword"), Color::Rgb(0xcc, 0x99, 0xcc)); // C_PURPLE / base0E @@ -193,9 +291,9 @@ mod tests { #[test] fn dark_diff_tints_match_the_historical_constants() { - // The pixel-identity gate: `Theme::dark` must reproduce M3–M5's hand-tuned tints exactly. + // 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 = Theme::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)); @@ -208,4 +306,105 @@ mod tests { assert_eq!(t.selection_bg, Color::Rgb(30, 66, 66)); assert_eq!(t.outline_cursor_unfocused_bg, Color::Rgb(35, 38, 55)); } + + fn rgb(color: Color) -> (u8, u8, u8) { + match color { + Color::Rgb(r, g, b) => (r, g, b), + other => panic!("expected an RGB color, got {other:?}"), + } + } + + fn luminance(color: Color) -> u32 { + let (r, g, b) = rgb(color); + r as u32 + g as u32 + b as u32 + } + + /// Euclidean-ish distance (sum of absolute channel deltas) from `base00` — used as a proxy for + /// "how washed-out toward the background is this tint," since [`tint_toward`] blends linearly. + fn distance_from_base00(color: Color) -> u32 { + let (r, g, b) = rgb(color); + let (r0, g0, b0) = rgb(Base16::ONE_LIGHT.slot(0)); + r.abs_diff(r0) as u32 + g.abs_diff(g0) as u32 + b.abs_diff(b0) as u32 + } + + #[test] + fn light_base00_is_high_luminance() { + // A light scheme's background must be near-white, unlike dark's near-black base00. + assert!(luminance(Base16::ONE_LIGHT.slot(0)) > luminance(Base16::EIGHTIES_DARK.slot(0))); + assert!(luminance(Base16::ONE_LIGHT.slot(0)) > 600); // out of a 765 (255*3) max + } + + #[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); + } + + #[test] + fn light_subtle_and_strong_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)); + } + + #[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)); + } + + #[test] + fn light_cursor_and_selection_washes_are_distinct_and_outline_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) + ); + } + + #[test] + fn light_syntax_resolves_representative_captures_to_the_one_light_accents() { + let theme = Palette::light(); + let color = |name: &str| theme.syntax(capture_index(name).unwrap()); + assert_eq!(color("keyword"), Color::Rgb(0xa6, 0x26, 0xa4)); // base0E purple + assert_eq!(color("string"), Color::Rgb(0x50, 0xa1, 0x4f)); // base0B green + assert_eq!(color("comment"), Color::Rgb(0xa0, 0xa1, 0xa7)); // base03 + assert_eq!(color("function"), Color::Rgb(0x40, 0x78, 0xf2)); // base0D blue + assert_eq!(color("number"), Color::Rgb(0xd7, 0x5f, 0x00)); // base09 orange + assert_eq!(color("variable"), Color::Rgb(0x38, 0x3a, 0x42)); // base05 fg + } + + #[test] + fn for_theme_selects_light_dark_and_falls_auto_back_to_dark() { + use crate::config::Theme; + + assert_eq!( + Palette::for_theme(Theme::Light).del_subtle, + Palette::light().del_subtle + ); + assert_ne!( + Palette::for_theme(Theme::Light).del_subtle, + Palette::dark().del_subtle + ); + assert_eq!( + Palette::for_theme(Theme::Dark).del_subtle, + Palette::dark().del_subtle + ); + // 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 + ); + } } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 6cc50b3..72ae14b 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -24,7 +24,7 @@ use ratatui::Terminal; use workon_review::app::App; use workon_review::keymap::{Command, Dispatch, KeyPress, Keymap}; use workon_review::render; -use workon_review::theme::Theme; +use workon_review::theme::Palette; /// One event the review loop reacts to. `Tick` is now also the index-watcher's poll beat (see the /// module doc's note on locked decision #4) — `next_event`'s mapping and this enum otherwise stay @@ -302,7 +302,7 @@ fn install_panic_hook() { /// Run the review TUI's terminal lifecycle and main loop against `app`. Callers must have /// already loaded the initial file (`app.open_current()`) before calling this. -pub fn run(app: &mut App, keymap: &Keymap, theme: &Theme) -> io::Result<()> { +pub fn run(app: &mut App, keymap: &Keymap, theme: &Palette) -> io::Result<()> { install_panic_hook(); enable_raw_mode()?; let mut out = terminal_writer(); @@ -323,7 +323,7 @@ fn event_loop( terminal: &mut Terminal>, app: &mut App, keymap: &Keymap, - theme: &Theme, + theme: &Palette, ) -> io::Result<()> { let mut pending: Vec = Vec::new(); let mut quit = false; From c43413f26a35c6f56b65f97720c418100bc1bf35 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 02:13:25 -0400 Subject: [PATCH 055/203] feat(review): derive theme from terminal for theme=auto --- Cargo.lock | 1 + docs/adr/029-review-theming-base16-hybrid.md | 16 + git-workon-review/Cargo.toml | 1 + git-workon-review/src/lib.rs | 1 + git-workon-review/src/main.rs | 18 +- git-workon-review/src/terminal_query.rs | 644 +++++++++++++++++++ git-workon-review/src/theme.rs | 124 +++- 7 files changed, 792 insertions(+), 13 deletions(-) create mode 100644 git-workon-review/src/terminal_query.rs diff --git a/Cargo.lock b/Cargo.lock index 699a1fb..40b9a93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -967,6 +967,7 @@ dependencies = [ "git-workon-fixture", "git-workon-lib", "git2", + "libc", "miette", "predicates", "ratatui", diff --git a/docs/adr/029-review-theming-base16-hybrid.md b/docs/adr/029-review-theming-base16-hybrid.md index 5c57e8d..58793a4 100644 --- a/docs/adr/029-review-theming-base16-hybrid.md +++ b/docs/adr/029-review-theming-base16-hybrid.md @@ -89,6 +89,22 @@ stays authored. the curated scheme chosen by background luminance if `OSC 11` answered, else `dark`. tmux/screen/ssh non-response is handled by the timeout, never a hang. +**CS6 refinement — the diff-bg tints stay curated, only the scheme is derived.** In +implementation, `auto` derives the base16 **scheme** (the 16 slots → syntax + monochrome ramp) +from the terminal, but the **diff/cursor/selection tints stay curated by luminance** rather than +derived from the probed accents (`Palette::from_terminal`: syntax = `SYNTAX_SLOTS` over the probed +`Base16`; tints = `Palette::dark()`'s or `Palette::light()`'s tint fields, chosen by the luminance +of the probed `base00`). Two reasons the earlier "derive tints from `base08`/`base0B`" plan was +narrowed: (1) dark-tint derivation is unsolved (see the corrected Primitive section — a convex +blend toward a dark `base00` can't reproduce the hand-tuned washes, and a probed *dark* terminal +hits exactly that), and (2) deriving washes from an arbitrary terminal's accent is unpredictable +across the range of real terminal palettes. The value of `auto` — **code colors matching the +terminal** — is fully delivered by the probed syntax slots, which curated tints don't compromise; +the diff washes were already hand-tuned per luminance, so borrowing them loses nothing. The six +ANSI-less slots are still synthesized as above; `parse` → `build_base16` → `from_terminal` → the +`palette_for_auto` fallback decision are all pure and unit-tested, with only the timed `/dev/tty` +read left untested (see `terminal_query.rs`). + ## Consequences - Light/dark ships as curated base16 schemes now; **terminal-derivation is first-class from diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index e1d2596..f8e4485 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -37,6 +37,7 @@ clap_complete.workspace = true crossterm.workspace = true git-workon-lib.workspace = true git2.workspace = true +libc.workspace = true miette.workspace = true ratatui.workspace = true similar.workspace = true diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 34fa5e1..fb07145 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -30,5 +30,6 @@ pub mod refresh; pub mod render; pub mod stage_op; pub mod synthesis; +pub mod terminal_query; pub mod theme; pub mod wordiff; diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index f96a2a7..39914f4 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -6,8 +6,9 @@ use git2::Repository; use miette::{IntoDiagnostic, Result}; use workon_review::acquire::{diff_changeset, resolve_changesets}; use workon_review::app::{App, ChangesetView, Severity}; -use workon_review::config::ReviewConfig; +use workon_review::config::{self, ReviewConfig}; use workon_review::keymap::Keymap; +use workon_review::terminal_query; use workon_review::theme::Palette; /// A TUI for reviewing changesets @@ -56,12 +57,15 @@ fn main() -> Result<()> { }; // Resolve the palette selection the same way, before `repo` moves — a config-read error - // degrades to dark rather than aborting the review (CS5); `Palette::for_theme` handles the - // parsed-selection cases (including `Auto`'s CS6-deferred fallback to dark). - let theme = ReviewConfig::new(&repo) - .theme() - .map(Palette::for_theme) - .unwrap_or_else(|_| Palette::dark()); + // 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. + let theme = match ReviewConfig::new(&repo).theme() { + Ok(config::Theme::Auto) => terminal_query::detect_auto_palette(), + Ok(selection) => Palette::for_theme(selection), + Err(_) => Palette::dark(), + }; // 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 diff --git a/git-workon-review/src/terminal_query.rs b/git-workon-review/src/terminal_query.rs new file mode 100644 index 0000000..ececce2 --- /dev/null +++ b/git-workon-review/src/terminal_query.rs @@ -0,0 +1,644 @@ +//! The `theme = auto` terminal-derivation probe (ADR-029, CS6). +//! +//! `auto` derives the base16 *scheme* (syntax + monochrome ramp) from the terminal's own colors, +//! so code in the diff is highlighted in the same palette the user's terminal already uses. It +//! 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-029). +//! +//! ## Robustness is the whole point +//! +//! The probe is the single most terminal-fragile component in the review TUI, so its blast radius +//! is contained to "return a curated theme instead": +//! - **Never hangs.** The tty is set **non-blocking** and the whole read ([`read_replies`]) is +//! bounded by a hard `Instant` deadline. `read()` therefore can never block on a terminal that +//! doesn't answer (tmux without passthrough, ssh, CI, a dumb terminal) — it returns `WouldBlock` +//! and the deadline is the backstop. (A blocking read guarded only by `poll(2)` is NOT safe: +//! `poll` on a tty is unreliable on macOS — it can report spurious readability — and +//! `cfmakeraw` sets `VMIN=1`, so a blocking `read` after a bad `poll` waits forever.) +//! - **Never corrupts the terminal.** The probe runs BEFORE `tui::run` installs its own raw mode / +//! alternate screen. It saves the tty's `termios`, sets raw for the duration of the read, and +//! **always restores** the saved `termios` before returning — leaving the tty exactly as it was +//! found. A trailing drain consumes any bytes the terminal still owed so none leak into +//! crossterm's later input reads. +//! - **Any failure → `None`.** Can't open `/dev/tty`, not a tty, a partial/malformed reply, a +//! missing color, or a timeout all collapse to an empty [`ProbeResult`], and the pure +//! [`palette_for_auto`] decision turns that into a curated fallback. +//! +//! Everything above the thin `#[cfg(unix)]` tty read — parsing, the ANSI→base16 build, the +//! fallback decision — is pure and unit-tested with injected bytes; the tests never touch a real +//! terminal. + +use std::time::Duration; + +use ratatui::style::Color; + +use crate::theme::{self, tint_toward, Base16, Palette}; + +/// The colors read back from a terminal OSC probe. `ansi16` is `Some` only if **all 16** ANSI +/// colors answered (a partial answer is treated as no answer — see the module doc); `background` +/// and `foreground` are the `OSC 11`/`OSC 10` replies, each independently optional. A fully-empty +/// result (every field `None`) is what a failed or timed-out probe produces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ProbeResult { + pub ansi16: Option<[Color; 16]>, + pub background: Option, + pub foreground: Option, +} + +/// Resolve the palette for `theme = auto` by probing the controlling terminal. Always returns a +/// usable [`Palette`] — a terminal-derived one when the probe succeeds, a curated fallback +/// otherwise. This is the entry point `main.rs` calls; the timeout is the non-negotiable backstop +/// against a silent terminal. +pub fn detect_auto_palette() -> Palette { + palette_for_auto(&probe_terminal(Duration::from_millis(120))) +} + +/// The pure decision that turns a [`ProbeResult`] into a [`Palette`] (unit-tested with injected +/// results — it performs no I/O): +/// - A complete probe (all 16 ANSI colors **and** a background) → a terminal-derived palette +/// ([`Palette::from_terminal`] over the [`build_base16`] scheme). +/// - Otherwise, if only the background answered → the curated scheme picked by its luminance. +/// - Otherwise (no background at all) → curated dark, the safe default. +pub fn palette_for_auto(probe: &ProbeResult) -> Palette { + match (probe.ansi16, probe.background) { + (Some(ansi), Some(bg)) => Palette::from_terminal(build_base16(&ansi, bg, probe.foreground)), + (_, Some(bg)) => { + if theme::is_light_background(bg) { + Palette::light() + } else { + Palette::dark() + } + } + (_, None) => Palette::dark(), + } +} + +/// Map a probed ANSI-16 palette + background (+ optional foreground) onto the 16 base16 slots, +/// synthesizing the six slots ANSI has no equivalent for (ADR-029). The diff-critical slots +/// (`base00`/`base08`/`base0B`) are always real; the synthesized slots are secondary accents and +/// ramp intermediates: +/// - **Ramp** (`base01`/`base02`): interpolated `base00 → base03`. +/// - **Ramp** (`base04`/`base06`): interpolated across `base03 → base05 → base07`. +/// - **`base09` (orange):** blend of `base08` (red) toward `base0A` (yellow). +/// - **`base0F` (brown):** blend of `base09` toward `base08`. +/// +/// ANSI role mapping (the standard base16 ↔ ANSI correspondence): `base00`=bg, `base03`=ANSI 8 +/// (bright black), `base05`=fg or ANSI 7 (white), `base07`=ANSI 15 (bright white), `base08`=ANSI 1 +/// (red), `base0A`=ANSI 3 (yellow), `base0B`=ANSI 2 (green), `base0C`=ANSI 6 (cyan), `base0D`=ANSI +/// 4 (blue), `base0E`=ANSI 5 (magenta). +pub fn build_base16(ansi: &[Color; 16], background: Color, foreground: Option) -> Base16 { + let base00 = background; + let base03 = ansi[8]; // bright black + let base05 = foreground.unwrap_or(ansi[7]); // fg, else white + let base07 = ansi[15]; // bright white + let base08 = ansi[1]; // red + let base0a = ansi[3]; // yellow + let base0b = ansi[2]; // green + let base0c = ansi[6]; // cyan + let base0d = ansi[4]; // blue + let base0e = ansi[5]; // magenta + + // Synthesized: ramp intermediates + the two accents ANSI has no slot for. + let base01 = tint_toward(base00, base03, 1.0 / 3.0); + let base02 = tint_toward(base00, base03, 2.0 / 3.0); + let base04 = tint_toward(base03, base05, 0.5); + let base06 = tint_toward(base05, base07, 0.5); + let base09 = tint_toward(base08, base0a, 0.5); // orange between red and yellow + let base0f = tint_toward(base09, base08, 0.5); // brown + + Base16 { + slots: [ + base00, base01, base02, base03, base04, base05, base06, base07, base08, base09, base0a, + base0b, base0c, base0d, base0e, base0f, + ], + } +} + +/// Run the tty probe, collapsing any failure to an empty [`ProbeResult`]. On non-unix platforms +/// (no `/dev/tty` / `termios`) it always reports empty, so `auto` degrades to curated dark there. +fn probe_terminal(timeout: Duration) -> ProbeResult { + #[cfg(unix)] + { + match query_terminal_raw(&build_query(), timeout) { + Some(bytes) => parse_osc_replies(&bytes), + None => ProbeResult::default(), + } + } + #[cfg(not(unix))] + { + let _ = timeout; + ProbeResult::default() + } +} + +/// The bytes we write to the terminal: `OSC 4;n;?` for each of the 16 ANSI colors, then +/// `OSC 11;?` (background) and `OSC 10;?` (foreground), then a primary Device Attributes query +/// (`ESC [ c`). Terminals answer in order, so the DA1 reply is a sentinel: once we see it, every +/// OSC reply that is going to arrive already has (see [`has_da1_terminator`]). Each OSC query is +/// String-Terminated with `ESC \` (ST). +fn build_query() -> Vec { + let mut q = Vec::new(); + for n in 0..16 { + q.extend_from_slice(format!("\x1b]4;{n};?\x1b\\").as_bytes()); + } + q.extend_from_slice(b"\x1b]11;?\x1b\\"); + q.extend_from_slice(b"\x1b]10;?\x1b\\"); + q.extend_from_slice(b"\x1b[c"); // DA1 sentinel + q +} + +/// Parse one OSC color-spec body of the form `rgb:RRRR/GGGG/BBBB` (1–4 hex digits per channel, +/// per the XParseColor grammar terminals reply with) into an 8-bit [`Color::Rgb`]. Each channel is +/// scaled from its `0..=(16^digits - 1)` range to `0..=255`, so a 4-digit `cccc` yields `0xcc` +/// (the "take the high byte" the ADR describes) and a 2-digit `cc` yields `0xcc` too. Returns +/// `None` on any malformation (wrong prefix, missing channel, non-hex, empty/over-long channel). +fn parse_rgb_spec(spec: &str) -> Option { + let rest = spec.strip_prefix("rgb:")?; + let mut channels = rest.split('/'); + let r = parse_channel(channels.next()?)?; + let g = parse_channel(channels.next()?)?; + let b = parse_channel(channels.next()?)?; + if channels.next().is_some() { + return None; // more than three channels → malformed + } + Some(Color::Rgb(r, g, b)) +} + +/// Parse and 8-bit-scale one hex channel (`1..=4` digits). `None` on empty, over-long, or non-hex. +fn parse_channel(s: &str) -> Option { + if s.is_empty() || s.len() > 4 { + return None; + } + let value = u32::from_str_radix(s, 16).ok()?; + let max = (1u32 << (4 * s.len())) - 1; // 16^digits - 1 + // Round-to-nearest scale into 0..=255. + Some(((value * 255 + max / 2) / max) as u8) +} + +/// Slice out the payloads of every complete OSC sequence in `bytes`: the run between an `ESC ]` +/// introducer and its terminator (`BEL`, or `ESC \` ST). An unterminated trailing OSC is dropped. +fn osc_payloads(bytes: &[u8]) -> Vec<&[u8]> { + let mut out = Vec::new(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == 0x1b && bytes[i + 1] == b']' { + let start = i + 2; + let mut j = start; + let mut terminated = None; + while j < bytes.len() { + if bytes[j] == 0x07 { + terminated = Some((j, j + 1)); // BEL + break; + } + if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' { + terminated = Some((j, j + 2)); // ESC \ (ST) + break; + } + j += 1; + } + match terminated { + Some((content_end, next)) => { + out.push(&bytes[start..content_end]); + i = next; + } + None => break, // incomplete trailing OSC + } + } else { + i += 1; + } + } + out +} + +/// Parse a raw terminal reply buffer into a [`ProbeResult`]: pick out every `OSC 4;n;`, +/// `OSC 11;`, and `OSC 10;` reply and decode its color. `ansi16` is `Some` only when +/// all 16 indices decoded; a duplicate or out-of-range index is ignored, and the DA1 reply (and +/// any other noise) is skipped since it carries no OSC color prefix. +fn parse_osc_replies(bytes: &[u8]) -> ProbeResult { + let mut ansi: [Option; 16] = [None; 16]; + let mut background = None; + let mut foreground = None; + + for payload in osc_payloads(bytes) { + let Ok(s) = std::str::from_utf8(payload) else { + continue; + }; + if let Some(rest) = s.strip_prefix("4;") { + if let Some((index, spec)) = rest.split_once(';') { + if let (Ok(idx), Some(color)) = (index.parse::(), parse_rgb_spec(spec)) { + if idx < 16 { + ansi[idx] = Some(color); + } + } + } + } else if let Some(spec) = s.strip_prefix("11;") { + background = parse_rgb_spec(spec); + } else if let Some(spec) = s.strip_prefix("10;") { + foreground = parse_rgb_spec(spec); + } + } + + let ansi16 = if ansi.iter().all(Option::is_some) { + Some(std::array::from_fn(|i| ansi[i].expect("all-some checked"))) + } else { + None + }; + ProbeResult { + ansi16, + background, + foreground, + } +} + +/// Whether the buffer contains a complete DA1 reply (`ESC [ ? … c`) — our "the terminal is done +/// answering" sentinel. A `c` following an `ESC [ ?` control sequence introducer. +fn has_da1_terminator(bytes: &[u8]) -> bool { + let mut i = 0; + while i + 2 < bytes.len() { + if bytes[i] == 0x1b && bytes[i + 1] == b'[' && bytes[i + 2] == b'?' { + // Scan for the final `c` of this CSI sequence. + let mut j = i + 3; + while j < bytes.len() { + if bytes[j] == b'c' { + return true; + } + // A different final byte (letter) ends the CSI without being DA1; keep scanning + // the buffer for another `ESC [ ?`. + if bytes[j].is_ascii_alphabetic() { + break; + } + j += 1; + } + } + i += 1; + } + false +} + +/// Open the controlling `/dev/tty`, put it in raw mode, write `query`, and read the reply with a +/// hard total `timeout` — restoring the saved `termios` before returning on **every** path. This +/// is the one function the unit tests do NOT call (it needs a real tty); everything it feeds +/// ([`parse_osc_replies`], [`build_base16`], [`palette_for_auto`]) is pure and tested directly. +/// +/// Returns the raw reply bytes, or `None` if `/dev/tty` can't be opened, isn't a tty, or the read +/// yields nothing before the timeout. `None` and an empty read both degrade to the curated +/// fallback upstream. +#[cfg(unix)] +fn query_terminal_raw(query: &[u8], timeout: Duration) -> Option> { + use std::os::unix::io::AsRawFd; + + let mut tty = std::fs::File::options() + .read(true) + .write(true) + .open("/dev/tty") + .ok()?; + let fd = tty.as_raw_fd(); + + // Save the current termios; bail (leaving the tty untouched) if this isn't a tty. + let mut saved: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(fd, &mut saved) } != 0 { + return None; + } + + // Switch to raw so the OSC replies (terminated by ST/BEL, not newline) arrive uncooked and + // unechoed. Override cfmakeraw's `VMIN=1` with `VMIN=0, VTIME=1` (0.1s): a defensive backstop + // so that even if the `O_NONBLOCK` fcntl in `read_replies` were to fail, a blocking `read` + // still returns (empty) after 0.1s rather than hanging on a silent terminal. + let mut raw = saved; + unsafe { libc::cfmakeraw(&mut raw) }; + raw.c_cc[libc::VMIN] = 0; + raw.c_cc[libc::VTIME] = 1; + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 { + return None; // termios unchanged — nothing to restore + } + + let outcome = read_replies(&mut tty, fd, query, timeout); + + // Discard anything still in the terminal's input queue before handing the tty back — a + // terminal that answered our OSC queries may have more reply bytes buffered than `read_replies` + // consumed (or that arrived just after it stopped at the DA1 sentinel). Left there, they leak + // into crossterm's input once the TUI starts and get parsed as a burst of spurious key events + // (the "unresponsive + refresh churn on launch" seen with `theme = auto`). The probe runs + // before any real keypress, so flushing pending input is safe. + unsafe { libc::tcflush(fd, libc::TCIFLUSH) }; + + // ALWAYS restore, on success or failure. + unsafe { libc::tcsetattr(fd, libc::TCSANOW, &saved) }; + outcome +} + +/// The read half of [`query_terminal_raw`], factored out so `termios` restoration wraps it on +/// every exit. Writes `query`, then polls for replies until the DA1 sentinel arrives or the total +/// `timeout` elapses, then drains any straggler bytes so nothing leaks into later input reads. +#[cfg(unix)] +fn read_replies( + tty: &mut std::fs::File, + fd: std::os::unix::io::RawFd, + query: &[u8], + timeout: Duration, +) -> Option> { + use std::io::{Read, Write}; + use std::time::Instant; + + if tty.write_all(query).is_err() || tty.flush().is_err() { + return None; + } + + // Switch to non-blocking for the read: a silent terminal must yield `WouldBlock`, never a + // blocked `read`. The `Instant` deadline (not `poll`) is the sole timing authority. + set_nonblocking(fd); + + let deadline = Instant::now() + timeout; + let mut buf = Vec::with_capacity(512); + let mut chunk = [0u8; 256]; + + while Instant::now() < deadline { + match tty.read(&mut chunk) { + Ok(0) => break, // EOF + Ok(n) => { + buf.extend_from_slice(&chunk[..n]); + if has_da1_terminator(&buf) { + break; + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // No data yet — yield briefly and let the deadline bound the wait. + std::thread::sleep(Duration::from_millis(2)); + } + Err(_) => break, + } + } + + // Drain anything immediately available (e.g. a terminal that answered without a DA1) so it + // doesn't surface as spurious input once the TUI takes over the tty. Non-blocking, so this + // stops at the first `WouldBlock`. + loop { + match tty.read(&mut chunk) { + Ok(n) if n > 0 => buf.extend_from_slice(&chunk[..n]), + _ => break, + } + } + + if buf.is_empty() { + None + } else { + Some(buf) + } +} + +/// Set `O_NONBLOCK` on the fd so `read` returns `WouldBlock` instead of blocking when the terminal +/// has nothing (more) to say. Best-effort: a failed `fcntl` leaves the fd blocking, but the caller +/// only reaches here after a successful `tcgetattr`, and the deadline loop still bounds the wait in +/// the common case. The fd is closed when the `File` drops, so `O_NONBLOCK` needs no restoration. +#[cfg(unix)] +fn set_nonblocking(fd: std::os::unix::io::RawFd) { + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags >= 0 { + libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── OSC color-spec parsing ─────────────────────────────────────────────── + + #[test] + fn parse_rgb_spec_takes_the_high_byte_of_a_16_bit_channel() { + // The plan's worked example: cccc → cc, 9999 → 99. + assert_eq!( + parse_rgb_spec("rgb:cccc/9999/cccc"), + Some(Color::Rgb(0xcc, 0x99, 0xcc)) + ); + } + + #[test] + fn parse_rgb_spec_accepts_short_channels_and_scales_them() { + // 8-bit channels pass through unchanged. + assert_eq!( + parse_rgb_spec("rgb:ff/80/00"), + Some(Color::Rgb(0xff, 0x80, 0x00)) + ); + // A single hex digit f == 15/15 of full scale == 255. + assert_eq!(parse_rgb_spec("rgb:f/0/f"), Some(Color::Rgb(255, 0, 255))); + } + + #[test] + fn parse_rgb_spec_rejects_malformed_specs() { + assert_eq!(parse_rgb_spec("rgb:cccc/9999"), None); // too few channels + assert_eq!(parse_rgb_spec("rgb:cc/dd/ee/ff"), None); // too many channels + assert_eq!(parse_rgb_spec("cmyk:1/2/3"), None); // wrong prefix + assert_eq!(parse_rgb_spec("rgb:zz/00/00"), None); // non-hex + assert_eq!(parse_rgb_spec("rgb:/00/00"), None); // empty channel + assert_eq!(parse_rgb_spec("rgb:11111/00/00"), None); // over-long channel + } + + // ── Whole-reply parsing → ProbeResult ──────────────────────────────────── + + /// A complete, well-formed reply: 16 `OSC 4` colors + `OSC 11` bg + `OSC 10` fg + a DA1 tail. + fn full_reply() -> Vec { + let mut r = Vec::new(); + for n in 0..16u8 { + // A recognizable per-index color: R = n*16, so index 5 → 0x50…. + let hh = format!("{:02x}", n * 16); + r.extend_from_slice(format!("\x1b]4;{n};rgb:{hh}{hh}/2020/4040\x1b\\").as_bytes()); + } + r.extend_from_slice(b"\x1b]11;rgb:1a1a/1a1a/1a1a\x1b\\"); // dark bg + r.extend_from_slice(b"\x1b]10;rgb:d3d3/d0d0/c8c8\x1b\\"); // fg + r.extend_from_slice(b"\x1b[?62;c"); // DA1 + r + } + + #[test] + fn parse_osc_replies_decodes_a_complete_reply() { + let result = parse_osc_replies(&full_reply()); + let ansi = result.ansi16.expect("all 16 colors present"); + assert_eq!(ansi[0], Color::Rgb(0x00, 0x20, 0x40)); + assert_eq!(ansi[5], Color::Rgb(0x50, 0x20, 0x40)); + assert_eq!(ansi[15], Color::Rgb(0xf0, 0x20, 0x40)); + assert_eq!(result.background, Some(Color::Rgb(0x1a, 0x1a, 0x1a))); + assert_eq!(result.foreground, Some(Color::Rgb(0xd3, 0xd0, 0xc8))); + } + + #[test] + fn parse_osc_replies_treats_a_missing_ansi_color_as_no_ansi() { + // Drop index 7's reply: the remaining 15 must NOT yield a partial ansi16. + let mut r = Vec::new(); + for n in 0..16u8 { + if n == 7 { + continue; + } + r.extend_from_slice(format!("\x1b]4;{n};rgb:1010/2020/3030\x1b\\").as_bytes()); + } + r.extend_from_slice(b"\x1b]11;rgb:1a1a/1a1a/1a1a\x1b\\"); + let result = parse_osc_replies(&r); + assert_eq!(result.ansi16, None, "an incomplete ANSI set is no set"); + // ...but the background still parsed, so the fallback can use its luminance. + assert_eq!(result.background, Some(Color::Rgb(0x1a, 0x1a, 0x1a))); + } + + #[test] + fn parse_osc_replies_of_garbage_is_empty() { + assert_eq!(parse_osc_replies(b""), ProbeResult::default()); + assert_eq!( + parse_osc_replies(b"not an escape sequence"), + ProbeResult::default() + ); + // An unterminated OSC is dropped rather than mis-parsed. + assert_eq!( + parse_osc_replies(b"\x1b]11;rgb:1a1a/1a1a/1a1a"), + ProbeResult::default() + ); + } + + #[test] + fn osc_payloads_handles_both_bel_and_st_terminators() { + let bytes = b"\x1b]11;rgb:aa/bb/cc\x07\x1b]10;rgb:11/22/33\x1b\\"; + let payloads = osc_payloads(bytes); + assert_eq!(payloads.len(), 2); + assert_eq!(payloads[0], b"11;rgb:aa/bb/cc"); + assert_eq!(payloads[1], b"10;rgb:11/22/33"); + } + + #[test] + fn da1_terminator_detected_only_when_complete() { + assert!(has_da1_terminator(b"\x1b[?62;1;c")); + assert!(has_da1_terminator(b"prefix\x1b[?6c trailing")); + assert!(!has_da1_terminator(b"\x1b[?62;1")); // no final c yet + assert!(!has_da1_terminator(b"\x1b[c")); // DA1 request, not a DA1 reply (no ?) + assert!(!has_da1_terminator(b"")); + } + + // ── ANSI-16 → Base16 build + synthesis ─────────────────────────────────── + + /// A recognizable ANSI-16 array: each color's red channel is its index * 16. + fn sample_ansi() -> [Color; 16] { + std::array::from_fn(|i| Color::Rgb((i as u8) * 16, 0x40, 0x80)) + } + + #[test] + fn build_base16_maps_ansi_roles_onto_the_right_slots() { + let ansi = sample_ansi(); + let bg = Color::Rgb(0x20, 0x20, 0x20); + let base = build_base16(&ansi, bg, Some(Color::Rgb(0xd0, 0xd0, 0xd0))); + + assert_eq!(base.slots[0], bg); // base00 = background + assert_eq!(base.slots[3], ansi[8]); // base03 = bright black + assert_eq!(base.slots[5], Color::Rgb(0xd0, 0xd0, 0xd0)); // base05 = foreground + assert_eq!(base.slots[7], ansi[15]); // base07 = bright white + assert_eq!(base.slots[8], ansi[1]); // base08 = red + assert_eq!(base.slots[10], ansi[3]); // base0A = yellow + assert_eq!(base.slots[11], ansi[2]); // base0B = green + assert_eq!(base.slots[12], ansi[6]); // base0C = cyan + assert_eq!(base.slots[13], ansi[4]); // base0D = blue + assert_eq!(base.slots[14], ansi[5]); // base0E = magenta + } + + #[test] + fn build_base16_falls_back_to_ansi7_when_no_foreground_probed() { + let ansi = sample_ansi(); + let base = build_base16(&ansi, Color::Rgb(0x20, 0x20, 0x20), None); + assert_eq!(base.slots[5], ansi[7]); // base05 = white when OSC 10 didn't answer + } + + fn channels(color: Color) -> (i32, i32, i32) { + match color { + Color::Rgb(r, g, b) => (r as i32, g as i32, b as i32), + other => panic!("expected RGB, got {other:?}"), + } + } + + #[test] + fn build_base16_synthesizes_a_monotonic_dark_ramp() { + // base00 (dark) → base01 → base02 → base03 must climb in luminance. + let ansi = sample_ansi(); + let base = build_base16(&ansi, Color::Rgb(0x10, 0x10, 0x10), None); + let lum = |i: usize| { + let (r, g, b) = channels(base.slots[i]); + r + g + b + }; + assert!(lum(0) <= lum(1), "base00 ≤ base01"); + assert!(lum(1) <= lum(2), "base01 ≤ base02"); + assert!(lum(2) <= lum(3), "base02 ≤ base03"); + } + + #[test] + fn build_base16_synthesizes_orange_between_red_and_yellow() { + // base09 (orange) is the midpoint of base08 (red) and base0A (yellow), per channel. + let red = Color::Rgb(0xf0, 0x00, 0x00); + let yellow = Color::Rgb(0xf0, 0xf0, 0x00); + let mut ansi = sample_ansi(); + ansi[1] = red; // base08 + ansi[3] = yellow; // base0A + let base = build_base16(&ansi, Color::Rgb(0x20, 0x20, 0x20), None); + let (r, g, b) = channels(base.slots[9]); // base09 + let (rr, rg, _) = channels(red); + let (_, yg, _) = channels(yellow); + assert_eq!(r, rr, "orange keeps the shared red channel"); + assert!(g > rg && g < yg, "orange green sits between red and yellow"); + assert_eq!(b, 0, "orange blue stays at zero"); + } + + // ── palette_for_auto decision ──────────────────────────────────────────── + + #[test] + fn palette_for_auto_builds_a_terminal_palette_from_a_complete_probe() { + let probe = ProbeResult { + ansi16: Some(sample_ansi()), + background: Some(Color::Rgb(0x1a, 0x1a, 0x1a)), // dark + foreground: Some(Color::Rgb(0xd0, 0xd0, 0xd0)), + }; + let palette = palette_for_auto(&probe); + // Syntax comes from the PROBED scheme (keyword → base0E = ANSI magenta = ansi[5]). + let expected = build_base16( + &sample_ansi(), + Color::Rgb(0x1a, 0x1a, 0x1a), + Some(Color::Rgb(0xd0, 0xd0, 0xd0)), + ); + 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); + } + + #[test] + fn palette_for_auto_falls_back_to_curated_by_bg_luminance_when_ansi_is_missing() { + // No ANSI colors, but the background answered light → curated light. + let light_bg = ProbeResult { + ansi16: None, + background: Some(Color::Rgb(0xf5, 0xf5, 0xf5)), + foreground: None, + }; + assert_eq!( + palette_for_auto(&light_bg).del_subtle, + Palette::light().del_subtle + ); + + // Background answered dark → curated dark. + let dark_bg = ProbeResult { + ansi16: None, + background: Some(Color::Rgb(0x1a, 0x1a, 0x1a)), + foreground: None, + }; + assert_eq!( + palette_for_auto(&dark_bg).del_subtle, + Palette::dark().del_subtle + ); + } + + #[test] + 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 + ); + } +} diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 5e6b554..2d2b64d 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -84,7 +84,7 @@ impl Base16 { /// describes for a LIGHT base00: blending an accent toward a light background yields a pale, /// correctly-hued wash (the dark scheme can't use this — see [`Palette::dark`]'s doc comment for /// why dark tints are held explicit instead). Non-RGB colors pass through unblended. -fn tint_toward(color: Color, base: Color, ratio: f32) -> Color { +pub(crate) fn tint_toward(color: Color, base: Color, ratio: f32) -> Color { match (color, base) { (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => { let lerp = @@ -95,6 +95,19 @@ fn tint_toward(color: Color, base: Color, ratio: f32) -> Color { } } +/// Whether a background color reads as "light" — a sum-of-channels luminance proxy (matching the +/// reasoning in this module's tests) with the midpoint of the `0..=765` range as the threshold. +/// Used to pick which curated scheme's diff/cursor tints a probed or fallback theme borrows +/// (CS6): a probed dark background reuses [`Palette::dark`]'s hand-tuned tints, a light one reuses +/// [`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 { + match color { + Color::Rgb(r, g, b) => r as u32 + g as u32 + b as u32 > 382, + _ => false, + } +} + /// Per-capture syntax template: each entry is the base16 slot index that the parallel /// [`crate::highlight::HIGHLIGHT_NAMES`] capture maps to, per the base16 role conventions /// (ADR-029). Palette-invariant — every scheme applies this same template to its own slots — so it @@ -244,6 +257,37 @@ impl Palette { } } + /// A scheme derived from the terminal's own colors (ADR-029'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-029: 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. + pub fn from_terminal(base: Base16) -> Self { + let curated = if is_light_background(base.slot(0)) { + Palette::light() + } else { + Palette::dark() + }; + 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, + cursor_bg: curated.cursor_bg, + selection_bg: curated.selection_bg, + outline_cursor_unfocused_bg: curated.outline_cursor_unfocused_bg, + } + } + /// The syntax foreground for a capture index (position in /// [`crate::highlight::HIGHLIGHT_NAMES`]). This is the render-time resolution the whole /// mechanism turns on: [`crate::highlight::FgSpan`] carries the index, the renderer resolves @@ -253,15 +297,17 @@ impl Palette { self.syntax[capture] } - /// Resolve the on-tint palette for a `workon.review.theme` selection (ADR-029/CS5). `Auto` - /// falls back to dark for now — CS6 adds the terminal-derivation probe that gives `Auto` its - /// real meaning. A config-read error is the caller's concern (see `main.rs`): this function - /// only handles a successfully-parsed selection. + /// Resolve the on-tint palette for a `workon.review.theme` selection (ADR-029/CS5) — the + /// **I/O-free** cases. `Light`/`Dark` return their curated schemes. `Auto` is the terminal + /// probe's job ([`crate::terminal_query::detect_auto_palette`], CS6), which needs tty access + /// this pure function can't have; `main.rs` routes `Auto` there and only falls through to this + /// function's dark result if it declines to probe. A config-read error is likewise the + /// caller's concern (see `main.rs`): this handles only a successfully-parsed selection. pub fn for_theme(theme: crate::config::Theme) -> Self { match theme { crate::config::Theme::Light => Self::light(), crate::config::Theme::Dark => Self::dark(), - crate::config::Theme::Auto => Self::dark(), // CS6: terminal-derive + crate::config::Theme::Auto => Self::dark(), // probe lives in main.rs/terminal_query } } } @@ -385,6 +431,72 @@ mod tests { assert_eq!(color("variable"), Color::Rgb(0x38, 0x3a, 0x42)); // base05 fg } + /// A synthetic probed scheme with a distinct value in every slot and the given `base00`, so a + /// test can assert `from_terminal`'s syntax slots came from the probed scheme (not a curated + /// one) and read the base00 luminance branch. + fn probed_base16(base00: Color) -> Base16 { + let mut slots = [Color::Rgb(0, 0, 0); 16]; + for (i, slot) in slots.iter_mut().enumerate() { + // A unique, recognizable color per slot: R channel = slot index * 16. + *slot = Color::Rgb((i as u8) * 16, 0x20, 0x40); + } + slots[0] = base00; + Base16 { slots } + } + + #[test] + fn from_terminal_takes_syntax_from_the_probed_scheme() { + let probed = probed_base16(Color::Rgb(0x1a, 0x1a, 0x1a)); // dark bg + let palette = Palette::from_terminal(probed); + // keyword → base0E (slot 14): the probed scheme's slot, NOT a curated palette's. + assert_eq!( + palette.syntax(capture_index("keyword").unwrap()), + probed.slot(14) + ); + assert_eq!( + palette.syntax(capture_index("string").unwrap()), + probed.slot(11) // base0B + ); + assert_ne!( + palette.syntax(capture_index("keyword").unwrap()), + Palette::dark().syntax(capture_index("keyword").unwrap()) + ); + } + + #[test] + fn from_terminal_with_a_dark_background_borrows_darks_curated_tints() { + 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 + ); + } + + #[test] + fn from_terminal_with_a_light_background_borrows_lights_curated_tints() { + 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); + } + + #[test] + fn is_light_background_splits_on_the_luminance_midpoint() { + assert!(is_light_background(Base16::ONE_LIGHT.slot(0))); + assert!(!is_light_background(Base16::EIGHTIES_DARK.slot(0))); + // A non-RGB color (never produced by the probe) reads as dark. + assert!(!is_light_background(Color::Gray)); + } + #[test] fn for_theme_selects_light_dark_and_falls_auto_back_to_dark() { use crate::config::Theme; From 85dfef3783881c4af37b3d2999568e973cf9a343 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 09:15:18 -0400 Subject: [PATCH 056/203] fix(review): paint themed canvas so light/dark control bg and fg --- docs/adr/029-review-theming-base16-hybrid.md | 19 +- git-workon-review/src/render.rs | 233 +++++++++++++++---- git-workon-review/src/theme.rs | 93 +++++++- 3 files changed, 294 insertions(+), 51 deletions(-) diff --git a/docs/adr/029-review-theming-base16-hybrid.md b/docs/adr/029-review-theming-base16-hybrid.md index 58793a4..8b71263 100644 --- a/docs/adr/029-review-theming-base16-hybrid.md +++ b/docs/adr/029-review-theming-base16-hybrid.md @@ -29,10 +29,21 @@ is spec-conformant. - **On a tint → base16 truecolor (theme-controlled):** diff add/del subtle/strong + staged variants, cursor, selection, and **syntax**. Contrast is guaranteed because foreground and background come from the *same* scheme. -- **Chrome, not on a tint → ANSI-named (`Color::Gray`/`DarkGray`/…):** gutter, borders, - footer, dim labels, status. These inherit the terminal palette, self-adapt light/dark, and - are **probe-independent** (work even when terminal-derivation fails). Half already are - ANSI-named today. +- **Chrome (default text, dim labels, gutter/dividers) + the canvas background → + base16-ramp-controlled (revised post-CS6):** originally these were ANSI-named + (`Color::Gray`/`DarkGray`) and the canvas was never painted, on the theory that inheriting + the terminal's own bg/fg would self-adapt for free. In practice this broke explicit + `light`/`dark` selections outright — the terminal's own (often dark) bg/fg bled straight + through a "light" theme, since nothing ever painted over it. Fixed: `Palette::background` + (base00)/`foreground` (base05)/`dim` (base03)/`gutter` (base04) are now real palette + fields, and `render()` paints the whole frame with `background` first when + `Palette::paint_canvas` is set. `dark()`/`light()` set `paint_canvas: true` — a curated + theme now fully controls the look, canvas included. `from_terminal` (`auto`) still derives + these four straight from the probed terminal colors — so it matches the terminal exactly, + as before — but sets `paint_canvas: false`, since `auto`'s base00 *is* the terminal's own + background; painting over it would flatten terminal transparency/background images for no + gain. The probe-failure fallback (`dark()`/`light()`) paints normally. Chrome that is + never a theme knob (error/warn/current-marker) stays ANSI/const in `render.rs`, unchanged. **Primitive — the theme is a base16 scheme.** A `Palette` holds the 16 slots (base00–07 mono ramp + base08–0F accents). Syntax uses the accents via the existing diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 7b16962..f7cf9b9 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -24,19 +24,15 @@ use crate::theme::Palette; use crate::wordiff::Span as WordSpan; // The on-tint colors (diff add/del gradient + staged variants, cursor/selection washes, and syntax -// foreground) now come from a [`Palette`] threaded through render (ADR-029). The chrome colors below -// stay ANSI-named / const here: they never sit on a tint, so they inherit the terminal palette and -// self-adapt light/dark, independent of the theme (the hybrid boundary — see the `theme` module). - -/// Default foreground for diff text that carries no syntax highlight — an ANSI gray that inherits -/// the terminal palette (chrome, not on-tint). Syntax-highlighted text resolves its fg from the -/// [`Palette`] instead (see [`compose_segments`]). -const FG_DEFAULT: Color = Color::Gray; -const FG_DIM: Color = Color::DarkGray; +// foreground) come from a [`Palette`] threaded through render (ADR-029). The canvas background and +// default/dim/gutter chrome foreground ALSO now come from the palette (`theme.background`/ +// `theme.foreground`/`theme.dim`/`theme.gutter`) — see the theme module's revised hybrid-boundary +// doc comment — so a curated theme fully controls the look. Only semantic chrome that is never a +// theme knob (error/warn/current-marker) stays ANSI-named / const below. + /// Footer text color for an [`Severity::Error`] [`Notice`] — a clearly-red tone that reads on /// both light and dark terminal themes. const FG_ERROR: Color = Color::Rgb(220, 60, 60); -const FG_GUTTER: Color = Color::DarkGray; /// Warning tone for the winbar's needs-restack marker (locked decision #9) — an amber, distinct /// from [`FG_ERROR`]'s red: a stale-parent changeset is a heads-up to `gt restack`, not a failure. const FG_WARN: Color = Color::Rgb(214, 158, 46); @@ -105,7 +101,7 @@ struct Segment { /// 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-029's render-time resolution) — a segment with no covering -/// syntax span falls back to [`FG_DEFAULT`]. +/// syntax span falls back to [`Palette::foreground`]. fn compose_segments( len: usize, bg_spans: &[(usize, usize, Color)], @@ -145,7 +141,7 @@ fn compose_segments( 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(FG_DEFAULT); + .unwrap_or(theme.foreground); segments.push(Segment { start, end, bg, fg }); } segments @@ -279,7 +275,7 @@ fn content_spans( if segments.is_empty() && !text.is_empty() { spans.push(TSpan::styled( text.to_string(), - Style::default().fg(FG_DEFAULT), + Style::default().fg(theme.foreground), )); } for seg in segments { @@ -309,7 +305,7 @@ fn build_pane_line( match row { Row::Filler => { let pattern: String = "╱".repeat(content_w + gutter_w + 1); - Line::from(TSpan::styled(pattern, Style::default().fg(FG_DIM))) + Line::from(TSpan::styled(pattern, Style::default().fg(theme.dim))) } Row::Line(n) => { let text = match side { @@ -323,7 +319,7 @@ fn build_pane_line( .and_then(|v| v.get(n - 1)); let gutter = format!("{n:>gutter_w$} "); - let mut spans = vec![TSpan::styled(gutter, Style::default().fg(FG_GUTTER))]; + 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)), @@ -347,10 +343,24 @@ fn build_pane_line( /// 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 -/// (CS4: always dark) on-tint palette — see [`crate::theme`]; the diff body, syntax foreground, -/// and cursor/selection washes all resolve their colors against it at paint time. +/// 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 +/// default/dim/gutter chrome foreground (ADR-029, revised). pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette) { let area = frame.area(); + + // Paint the whole screen with the theme's background FIRST — a curated theme (light/dark) + // controls the canvas outright; `auto` leaves `paint_canvas` false so the terminal's own + // background (and any transparency) shows through instead. Everything drawn below only sets + // `fg` (never `bg`) unless it's specifically painting a tint, so this base coat survives under + // plain text and is overridden cleanly by the diff-tint/cursor/selection washes. + if theme.paint_canvas { + frame.render_widget( + Block::default().style(Style::default().bg(theme.background)), + area, + ); + } + let vlayout = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -364,8 +374,8 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette let body_area = vlayout[1]; let footer_area = vlayout[2]; - render_header(frame, app, header_area); - render_footer(frame, app, footer_area, keymap); + render_header(frame, app, header_area, theme); + render_footer(frame, app, footer_area, keymap, theme); if app.outline_open() { let hlayout = Layout::default() @@ -383,7 +393,7 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette for y in div_area.y..div_area.y + div_area.height { frame .buffer_mut() - .set_string(div_area.x, y, "│", Style::default().fg(FG_DIM)); + .set_string(div_area.x, y, "│", Style::default().fg(theme.dim)); } render_body(frame, app, diff_area, theme); } else { @@ -485,7 +495,7 @@ fn render_outline(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { continue; }; let is_cursor = item_idx == cursor; - let line = build_outline_line(item); + let line = build_outline_line(item, theme); let line = if is_cursor && focused { apply_cursor_row(line, area.width, theme) } else if is_cursor { @@ -519,7 +529,7 @@ fn tree_prefix(guides: &[bool]) -> String { /// Build one outline row's rendered [`Line`] — see [`render_outline`]'s doc comment for the /// marker rules. -fn build_outline_line(item: &OutlineItem) -> Line<'static> { +fn build_outline_line(item: &OutlineItem, theme: &Palette) -> Line<'static> { match item { OutlineItem::Header { label, @@ -534,7 +544,9 @@ fn build_outline_line(item: &OutlineItem) -> Line<'static> { )]; spans.push(TSpan::styled( label.clone(), - Style::default().add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), )); if *needs_restack { spans.push(TSpan::styled(" \u{26A0}", Style::default().fg(FG_WARN))); @@ -545,7 +557,9 @@ fn build_outline_line(item: &OutlineItem) -> Line<'static> { let text = format!("{}{name}/", tree_prefix(guides)); Line::from(TSpan::styled( text, - Style::default().fg(FG_DIM).add_modifier(Modifier::ITALIC), + Style::default() + .fg(theme.dim) + .add_modifier(Modifier::ITALIC), )) } OutlineItem::File { @@ -564,7 +578,7 @@ fn build_outline_line(item: &OutlineItem) -> Line<'static> { tree_prefix(guides) }; let text = format!("{prefix}{glyph} {path}"); - Line::from(TSpan::styled(text, Style::default().fg(FG_DEFAULT))) + Line::from(TSpan::styled(text, Style::default().fg(theme.foreground))) } } } @@ -591,16 +605,20 @@ fn current_file_label(app: &App) -> String { /// 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) { +fn render_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { if app.changeset_count() > 1 { - render_winbar(frame, app, area); + 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)); frame.render_widget( - Paragraph::new(text).style(Style::default().add_modifier(Modifier::BOLD)), + Paragraph::new(text).style( + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), + ), area, ); } @@ -610,7 +628,7 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { /// 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. -fn render_winbar(frame: &mut Frame, app: &App, area: Rect) { +fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { let cs = app.current_changeset(); let i = app.current_cs() + 1; let n = app.changeset_count(); @@ -618,7 +636,9 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect) { let mut spans = vec![TSpan::styled( format!("[{i}/{n}] {title}"), - Style::default().add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), )]; // 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. @@ -632,7 +652,9 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect) { let nfiles = app.files().len(); spans.push(TSpan::styled( format!(" — {} ({fidx}/{nfiles})", current_file_label(app)), - Style::default().add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), )); frame.render_widget(Paragraph::new(Line::from(spans)), area); @@ -641,7 +663,7 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect) { /// Footer priority: a pending discard confirm's prompt (warn-toned) wins over a transient notice, /// which wins over the curated hint line (CS3) — a notice TEMPORARILY REPLACES the hint rather /// than adding a second row; it clears on the user's next keypress (`tui::update`). -fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap) { +fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, theme: &Palette) { if let Some(confirm) = &app.pending_confirm { frame.render_widget( Paragraph::new(confirm.prompt.as_str()).style(Style::default().fg(FG_ERROR)), @@ -653,7 +675,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap) { Some(Notice { text, severity }) => { let fg = match severity { Severity::Error => FG_ERROR, - Severity::Info => FG_DEFAULT, + Severity::Info => theme.foreground, }; frame.render_widget( Paragraph::new(text.as_str()).style(Style::default().fg(fg)), @@ -672,7 +694,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap) { }; let text = footer_hint(keymap, focused); frame.render_widget( - Paragraph::new(text).style(Style::default().fg(FG_DIM)), + Paragraph::new(text).style(Style::default().fg(theme.dim)), area, ); } @@ -692,7 +714,7 @@ fn render_gap_row( theme: &Palette, ) { let msg = format!("··· {skipped} unchanged lines ···"); - let line = Line::from(TSpan::styled(msg, Style::default().fg(FG_DIM))); + 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) @@ -713,7 +735,10 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { let idx = app.current; if app.files()[idx].is_binary { let msg = format!("[Binary file: {}]", app.files()[idx].path); - frame.render_widget(Paragraph::new(msg).style(Style::default().fg(FG_DIM)), area); + frame.render_widget( + Paragraph::new(msg).style(Style::default().fg(theme.dim)), + area, + ); return; } @@ -786,8 +811,8 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t app.derive_scroll(); app.derive_alt_scroll(); - render_caption(frame.buffer_mut(), unstaged_caption, "UNSTAGED"); - render_caption(frame.buffer_mut(), staged_caption, "STAGED"); + render_caption(frame.buffer_mut(), unstaged_caption, "UNSTAGED", theme); + render_caption(frame.buffer_mut(), staged_caption, "STAGED", theme); let (u_scroll, u_cursor) = app.pane_render_state(Role::Unstaged); let (s_scroll, s_cursor) = app.pane_render_state(Role::Staged); @@ -850,9 +875,9 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t /// 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) { +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(FG_DIM))); + let line = Line::from(TSpan::styled(text, Style::default().fg(theme.dim))); buf.set_line(area.x, area.y, &line, area.width); } @@ -921,7 +946,7 @@ fn render_pane_sbs( for y in area.y..area.y + area.height { frame .buffer_mut() - .set_string(div_area.x, y, "│", Style::default().fg(FG_DIM)); + .set_string(div_area.x, y, "│", Style::default().fg(theme.dim)); } for (i, row_idx) in (scroll..end).enumerate() { @@ -1000,7 +1025,7 @@ fn render_pane_sbs( div_area.x, y, "│", - Style::default().fg(FG_DIM).bg(theme.cursor_bg), + Style::default().fg(theme.dim).bg(theme.cursor_bg), ); } } @@ -1065,7 +1090,7 @@ fn build_inline_line( gutter_field(old_opt, old_gutter_w), gutter_field(new_opt, new_gutter_w) ); - let mut spans = vec![TSpan::styled(gutter, Style::default().fg(FG_GUTTER))]; + let mut spans = vec![TSpan::styled(gutter, Style::default().fg(theme.gutter))]; let is_word_pair = row.is_word_diff_pair(); // `kind` is always Del/Add/Context here — inline has no Filler rows. `old_opt`/`new_opt` @@ -1206,6 +1231,16 @@ 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() + } + fn cell_text(buf: &Buffer, x: u16, y: u16) -> &str { buf.cell((x, y)).unwrap().symbol() } @@ -2421,4 +2456,116 @@ mod tests { content.join("\n") ); } + + // ── theming fix: canvas paint ──────────────────────────────────────────────── + + #[test] + fn light_theme_paints_the_canvas_with_the_light_background() { + // The bug this fix addresses: `workon.review.theme light` still showed the terminal's own + // (usually dark) bg/fg because the canvas was never painted. A body cell untouched by any + // diff/cursor/selection tint (e.g. a blank row past the end of a short file) must carry + // the theme's OWN background, not `None`/the terminal default. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + let theme = Palette::light(); + let buf = render_once_themed(&mut app, 40, 10, &theme); + + // Row 1 (below the header, no files loaded — "(no changes)" placeholder) is plain canvas: + // no tint should have painted over it. + let canvas_cell = buf.cell((30, 5)).unwrap(); + assert_eq!( + canvas_cell.style().bg, + Some(theme.background), + "expected an untinted body cell to carry the light theme's painted canvas background" + ); + } + + #[test] + fn header_text_carries_the_theme_foreground_not_the_terminal_default() { + // Regression (stack-review): render_header/render_winbar drew BOLD text with no `.fg()`, + // so on a curated theme whose polarity differs from the terminal the top bar rendered in + // the terminal's default fg over the painted canvas — invisible (light-on-light for + // `theme=light` in a dark terminal). The header must carry the theme's own foreground. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + let theme = Palette::light(); + let buf = render_once_themed(&mut app, 40, 10, &theme); + + // Cell (0,0) is the header's leading '[' — a real glyph in the top status bar. + let header_cell = buf.cell((0, 0)).unwrap(); + assert_eq!( + header_cell.style().fg, + Some(theme.foreground), + "header text must use the theme foreground to stay visible on the painted canvas" + ); + } + + #[test] + fn dark_theme_paints_the_canvas_with_the_dark_background() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + let theme = Palette::dark(); + let buf = render_once_themed(&mut app, 40, 10, &theme); + + let canvas_cell = buf.cell((30, 5)).unwrap(); + assert_eq!( + canvas_cell.style().bg, + Some(theme.background), + "expected an untinted body cell to carry the dark theme's painted canvas background" + ); + } + + #[test] + fn cursor_row_tint_still_shows_over_a_painted_canvas() { + // The canvas paint must not mask the per-row tint compositing (cursor/diff washes) — + // a cursor row must still show the theme's cursor tint, not the flat canvas color. + 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) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + let theme = Palette::light(); + + let cursor_row = app + .current_view_ref() + .unwrap() + .display + .iter() + .position(|row| matches!(row, DisplayRow::Row(r) if r.old == Row::Line(10))) + .expect("l10 row present in the display vector"); + app.cursor = cursor_row; + + let buf = render_once_themed(&mut app, 60, 20, &theme); + let content = buf_lines(&buf); + let cursor_y = content + .iter() + .position(|line| line.contains("l10 ")) + .expect("cursor row (l10) visible") as u16; + + let cursor_bg = buf.cell((1, cursor_y)).unwrap().style().bg; + assert_eq!( + cursor_bg, + Some(theme.cursor_bg), + "expected the cursor row to carry the theme's cursor tint over the painted canvas" + ); + assert_ne!( + cursor_bg, + Some(theme.background), + "the cursor tint must be visually distinct from the flat painted canvas" + ); + } } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 2d2b64d..cc4c475 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -6,12 +6,18 @@ //! CS5 adds [`Palette::light`] and wires [`crate::config::Theme`] to pick between them; CS6 adds the //! terminal-derivation probe for `auto`. //! -//! ## Hybrid boundary (ADR-029) +//! ## Hybrid boundary (ADR-029, revised) //! Colors that sit ON a tinted background — the diff add/del gradient, its staged variants, the //! cursor/selection washes, and syntax foreground — are theme-controlled base16 truecolor and live -//! here. Chrome that is NOT on a tint (gutter, dividers, footer, dim labels, status markers) stays -//! ANSI-named / const in [`crate::render`] so it self-adapts to the terminal palette and is -//! probe-independent. This module deliberately holds only the on-tint half. +//! here, as before. The canvas background and chrome FOREGROUND (default text, dim labels, the +//! gutter) are now ALSO palette-ramp-controlled ([`Palette::background`]/[`Palette::foreground`]/ +//! [`Palette::dim`]/[`Palette::gutter`]), so a curated (`light`/`dark`) theme fully controls the +//! look instead of bleeding the terminal's own bg/fg through. `auto` ([`Palette::from_terminal`]) +//! still derives these four from the probed terminal colors — so it matches the terminal exactly — +//! and leaves [`Palette::paint_canvas`] `false` so a transparent/backgrounded terminal isn't +//! painted over; the curated schemes and the probe's curated fallback set it `true`. Semantic +//! chrome that is never on a tint and never a theme knob — error/warn/current-marker colors — stays +//! ANSI/const in [`crate::render`] (`FG_ERROR`/`FG_WARN`/`FG_CURRENT`), unaffected by this boundary. use ratatui::style::Color; @@ -184,6 +190,23 @@ pub struct Palette { 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, + + /// 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 + /// the terminal's own. + pub background: Color, + /// Default text foreground (base05) — resolved by [`crate::render`] wherever text carries no + /// syntax highlight. + pub foreground: Color, + /// Dim/comment-toned foreground (base03) — dim labels, gap markers, split captions. + pub dim: Color, + /// Gutter/divider foreground (base04) — line-number gutters and pane dividers. + pub gutter: Color, + /// Whether [`crate::render::render`] should paint the whole frame with [`Palette::background`] + /// before drawing panes. `true` for the curated [`Palette::dark`]/[`Palette::light`] schemes + /// (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, } impl Palette { @@ -210,6 +233,11 @@ impl Palette { cursor_bg: Color::Rgb(45, 50, 90), selection_bg: Color::Rgb(30, 66, 66), outline_cursor_unfocused_bg: Color::Rgb(35, 38, 55), + background: base.slot(0), + foreground: base.slot(5), + dim: base.slot(3), + gutter: base.slot(4), + paint_canvas: true, } } @@ -254,6 +282,11 @@ impl Palette { 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), + background: base.slot(0), + foreground: base.slot(5), + dim: base.slot(3), + gutter: base.slot(4), + paint_canvas: true, } } @@ -285,6 +318,17 @@ impl Palette { cursor_bg: curated.cursor_bg, selection_bg: curated.selection_bg, outline_cursor_unfocused_bg: curated.outline_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), + dim: base.slot(3), + gutter: base.slot(4), + // Unlike the curated schemes, `auto` must NOT paint over the terminal's own + // background — base00 here IS the probed terminal bg, so painting a solid canvas + // 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, } } @@ -353,6 +397,33 @@ mod tests { assert_eq!(t.outline_cursor_unfocused_bg, Color::Rgb(35, 38, 55)); } + #[test] + fn dark_chrome_fields_match_the_eighties_dark_ramp_and_paint_the_canvas() { + // `dark()`'s canvas/chrome must come from the SAME ramp `Palette::dark`'s syntax/tints + // already use (base00/base03/base04/base05), and must paint (a curated theme fully + // controls the look — see the theme module's revised hybrid-boundary doc comment). + let t = Palette::dark(); + assert_eq!(t.background, Color::Rgb(0x2d, 0x2d, 0x2d)); // base00 + assert_eq!(t.foreground, Color::Rgb(0xd3, 0xd0, 0xc8)); // base05 + assert_eq!(t.dim, Color::Rgb(0x74, 0x73, 0x69)); // base03 + assert_eq!(t.gutter, Color::Rgb(0xa0, 0x9f, 0x93)); // base04 + assert!(t.paint_canvas); + } + + #[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 + // unpainted canvas would let the terminal's own dark bg bleed through, the exact bug this + // fix addresses). + let t = Palette::light(); + assert_eq!(t.background, Color::Rgb(0xfa, 0xfa, 0xfa)); // base00 + assert_eq!(t.foreground, Color::Rgb(0x38, 0x3a, 0x42)); // base05 + assert_eq!(t.dim, Color::Rgb(0xa0, 0xa1, 0xa7)); // base03 + assert_eq!(t.gutter, Color::Rgb(0x69, 0x6c, 0x77)); // base04 + assert!(luminance(t.background) > luminance(t.foreground)); + assert!(t.paint_canvas); + } + fn rgb(color: Color) -> (u8, u8, u8) { match color { Color::Rgb(r, g, b) => (r, g, b), @@ -489,6 +560,20 @@ mod tests { assert_ne!(palette.del_subtle, Palette::dark().del_subtle); } + #[test] + fn from_terminal_takes_chrome_from_the_probed_scheme_and_does_not_paint() { + // `auto`'s canvas/chrome must come from the PROBED scheme (so it matches the terminal), + // and must NOT paint — the terminal's own background stays, preserving transparency (see + // the theme module's revised hybrid-boundary doc comment). + let probed = probed_base16(Color::Rgb(0x1a, 0x1a, 0x1a)); + let palette = Palette::from_terminal(probed); + assert_eq!(palette.background, probed.slot(0)); + assert_eq!(palette.foreground, probed.slot(5)); + assert_eq!(palette.dim, probed.slot(3)); + assert_eq!(palette.gutter, probed.slot(4)); + assert!(!palette.paint_canvas); + } + #[test] fn is_light_background_splits_on_the_luminance_midpoint() { assert!(is_light_background(Base16::ONE_LIGHT.slot(0))); From 7dc063275b9ddb982292f4ca8d187871106d6316 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 22:43:37 -0400 Subject: [PATCH 057/203] fix(review): wait out zero-byte tty reads in theme=auto probe --- git-workon-review/src/main.rs | 13 +- git-workon-review/src/terminal_query.rs | 186 ++++++++++++++++++++---- 2 files changed, 168 insertions(+), 31 deletions(-) diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 39914f4..a7bb074 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -61,7 +61,9 @@ fn main() -> Result<()> { // 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. - let theme = match ReviewConfig::new(&repo).theme() { + let selection = ReviewConfig::new(&repo).theme(); + let probed = matches!(selection, Ok(config::Theme::Auto)); + let theme = match selection { Ok(config::Theme::Auto) => terminal_query::detect_auto_palette(), Ok(selection) => Palette::for_theme(selection), Err(_) => Palette::dark(), @@ -94,6 +96,15 @@ fn main() -> Result<()> { app.notify(warnings.join("; "), Severity::Error); } + // After a probe, OSC replies from a slow terminal (e.g. one ssh round-trip away) may have + // straggled in while the changesets were being assembled above. Discard them now, right + // before crossterm takes the terminal — parsed as input they become phantom keystrokes + // (`r` fires refreshes; `d` opens the discard confirm, which then swallows every key until + // Esc/n: the "unresponsive for ~30s with theme=auto" startup). Un-probed launches skip this + // so legitimate type-ahead survives. + if probed { + terminal_query::flush_pending_tty_input(); + } tui::run(&mut app, &keymap, &theme).into_diagnostic()?; Ok(()) diff --git a/git-workon-review/src/terminal_query.rs b/git-workon-review/src/terminal_query.rs index ececce2..f854473 100644 --- a/git-workon-review/src/terminal_query.rs +++ b/git-workon-review/src/terminal_query.rs @@ -13,12 +13,17 @@ //! //! The probe is the single most terminal-fragile component in the review TUI, so its blast radius //! is contained to "return a curated theme instead": -//! - **Never hangs.** The tty is set **non-blocking** and the whole read ([`read_replies`]) is -//! bounded by a hard `Instant` deadline. `read()` therefore can never block on a terminal that -//! doesn't answer (tmux without passthrough, ssh, CI, a dumb terminal) — it returns `WouldBlock` -//! and the deadline is the backstop. (A blocking read guarded only by `poll(2)` is NOT safe: -//! `poll` on a tty is unreliable on macOS — it can report spurious readability — and -//! `cfmakeraw` sets `VMIN=1`, so a blocking `read` after a bad `poll` waits forever.) +//! - **Never hangs, but always waits.** The tty is set **non-blocking** and the whole read +//! ([`read_replies`]) is bounded by a hard `Instant` deadline. `read()` therefore can never +//! block on a terminal that doesn't answer (tmux without passthrough, ssh, CI, a dumb +//! terminal) — a not-yet-answered read yields `WouldBlock` or, with the `VMIN=0` polling-read +//! semantics the probe sets, `Ok(0)`. BOTH mean "no data yet", never EOF: treating `Ok(0)` as +//! EOF made the deadline loop exit in microseconds, so the probe read nothing, the replies +//! arrived after the [`query_terminal_raw`] flush, leaked into crossterm, and froze input at +//! startup (the dogfood-round-2 wedge). Only the deadline and the DA1 sentinel end the wait. +//! (A blocking read guarded only by `poll(2)` is NOT safe: `poll` on a tty is unreliable on +//! macOS — it can report spurious readability — and `cfmakeraw` sets `VMIN=1`, so a blocking +//! `read` after a bad `poll` waits forever.) //! - **Never corrupts the terminal.** The probe runs BEFORE `tui::run` installs its own raw mode / //! alternate screen. It saves the tty's `termios`, sets raw for the duration of the read, and //! **always restores** the saved `termios` before returning — leaving the tty exactly as it was @@ -53,8 +58,31 @@ pub struct ProbeResult { /// usable [`Palette`] — a terminal-derived one when the probe succeeds, a curated fallback /// otherwise. This is the entry point `main.rs` calls; the timeout is the non-negotiable backstop /// against a silent terminal. +/// +/// The deadline is generous because it almost never bites: every interactive terminal answers the +/// DA1 sentinel (a VT100-era query), so the probe normally returns at the sentinel within a few +/// ms (or one network round-trip over ssh). Only a tty whose far end answers *nothing* waits the +/// full deadline — and giving up early on a merely-slow terminal is worse than the wait, because +/// replies that arrive after the probe stopped listening leak into crossterm as phantom +/// keystrokes (`r` → refresh storms, `d` → a discard confirm that captures the keyboard). pub fn detect_auto_palette() -> Palette { - palette_for_auto(&probe_terminal(Duration::from_millis(120))) + palette_for_auto(&probe_terminal(Duration::from_millis(800))) +} + +/// Discard any bytes pending on the controlling tty's input queue. `main.rs` calls this after the +/// `theme = auto` probe and immediately before the TUI takes over the terminal: OSC replies that +/// straggle in while the app is still assembling changesets (an ssh round-trip can outlast the +/// probe's deadline) would otherwise sit in the queue and reach crossterm as phantom keystrokes. +/// Only meaningful after a probe — an un-probed launch has no replies owed, and flushing would +/// discard legitimate type-ahead. +pub fn flush_pending_tty_input() { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + if let Ok(tty) = std::fs::File::options().read(true).open("/dev/tty") { + unsafe { libc::tcflush(tty.as_raw_fd(), libc::TCIFLUSH) }; + } + } } /// The pure decision that turns a [`ProbeResult`] into a [`Palette`] (unit-tested with injected @@ -348,34 +376,17 @@ fn read_replies( return None; } - // Switch to non-blocking for the read: a silent terminal must yield `WouldBlock`, never a - // blocked `read`. The `Instant` deadline (not `poll`) is the sole timing authority. + // Switch to non-blocking for the read: a silent terminal must yield `WouldBlock` (or the + // `VMIN=0` polling-read `Ok(0)`), never a blocked `read`. The `Instant` deadline (not `poll`) + // is the sole timing authority. set_nonblocking(fd); - let deadline = Instant::now() + timeout; - let mut buf = Vec::with_capacity(512); + let mut buf = collect_replies(|chunk| tty.read(chunk), Instant::now() + timeout); let mut chunk = [0u8; 256]; - while Instant::now() < deadline { - match tty.read(&mut chunk) { - Ok(0) => break, // EOF - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if has_da1_terminator(&buf) { - break; - } - } - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - // No data yet — yield briefly and let the deadline bound the wait. - std::thread::sleep(Duration::from_millis(2)); - } - Err(_) => break, - } - } - // Drain anything immediately available (e.g. a terminal that answered without a DA1) so it - // doesn't surface as spurious input once the TUI takes over the tty. Non-blocking, so this - // stops at the first `WouldBlock`. + // doesn't surface as spurious input once the TUI takes over the tty. Stops the moment nothing + // is pending (`Ok(0)` or `WouldBlock`), so it never waits. loop { match tty.read(&mut chunk) { Ok(n) if n > 0 => buf.extend_from_slice(&chunk[..n]), @@ -390,6 +401,40 @@ fn read_replies( } } +/// Accumulate terminal reply bytes from `read` until the DA1 sentinel arrives or `deadline` +/// passes — the read half of [`read_replies`], seamed on the reader so the loop's give-up +/// conditions are unit-testable without a tty. +/// +/// Both `Ok(0)` and `WouldBlock` mean "the terminal hasn't answered yet", NEVER end-of-file: +/// with the `VMIN=0` termios the probe sets, a tty `read` is a *polling read* that returns 0 +/// immediately when the queue is empty. Only a genuine read error ends the wait early — every +/// "no data yet" result just yields briefly and retries until the deadline. +#[cfg(unix)] +fn collect_replies( + mut read: impl FnMut(&mut [u8]) -> std::io::Result, + deadline: std::time::Instant, +) -> Vec { + let mut buf = Vec::with_capacity(512); + let mut chunk = [0u8; 256]; + + while std::time::Instant::now() < deadline { + match read(&mut chunk) { + Ok(n) if n > 0 => { + buf.extend_from_slice(&chunk[..n]); + if has_da1_terminator(&buf) { + break; + } + } + Ok(_) => std::thread::sleep(Duration::from_millis(2)), + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + } + Err(_) => break, + } + } + buf +} + /// Set `O_NONBLOCK` on the fd so `read` returns `WouldBlock` instead of blocking when the terminal /// has nothing (more) to say. Best-effort: a failed `fcntl` leaves the fd blocking, but the caller /// only reaches here after a successful `tcgetattr`, and the deadline loop still bounds the wait in @@ -507,6 +552,87 @@ mod tests { assert_eq!(payloads[1], b"10;rgb:11/22/33"); } + // ── collect_replies give-up conditions ─────────────────────────────────── + + /// A reader that scripts each successive `read` call's result: `Ok(&[u8])` delivers bytes, + /// `Err(kind)` returns that error kind. Exhausting the script yields `Ok(0)` ("no data yet"). + #[cfg(unix)] + fn scripted_reader( + script: Vec>, + ) -> impl FnMut(&mut [u8]) -> std::io::Result { + let mut steps = script.into_iter(); + move |chunk: &mut [u8]| match steps.next() { + Some(Ok(bytes)) => { + chunk[..bytes.len()].copy_from_slice(bytes); + Ok(bytes.len()) + } + Some(Err(kind)) => Err(kind.into()), + None => Ok(0), + } + } + + #[cfg(unix)] + fn soon() -> std::time::Instant { + std::time::Instant::now() + Duration::from_millis(200) + } + + #[cfg(unix)] + #[test] + fn collect_replies_treats_zero_byte_reads_as_pending_not_eof() { + // The dogfood-round-2 wedge: with `VMIN=0` a tty read returns `Ok(0)` while the terminal + // is still composing its answer. The loop must keep waiting — bailing here left the + // replies to arrive after the probe's flush and freeze crossterm's input at startup. + let read = scripted_reader(vec![ + Ok(b""), + Ok(b""), + Ok(b"\x1b]11;rgb:1a1a/1a1a/1a1a\x1b\\"), + Ok(b"\x1b[?62;22c"), + ]); + let buf = collect_replies(read, soon()); + assert!( + buf.starts_with(b"\x1b]11;"), + "replies after Ok(0) polling reads must still be collected" + ); + assert!(has_da1_terminator(&buf), "loop ran on to the DA1 sentinel"); + } + + #[cfg(unix)] + #[test] + fn collect_replies_stops_at_the_da1_sentinel() { + // Bytes offered after the DA1 reply must never be consumed — the sentinel ends the read + // so the probe returns promptly on a terminal that answered everything. + let read = scripted_reader(vec![Ok(b"\x1b[?62;22c"), Ok(b"leftover")]); + let buf = collect_replies(read, soon()); + assert_eq!(buf, b"\x1b[?62;22c"); + } + + #[cfg(unix)] + #[test] + fn collect_replies_waits_out_would_block_and_gives_up_at_the_deadline() { + // WouldBlock is the O_NONBLOCK "no data yet"; a terminal that never answers must yield + // an empty buffer once the deadline passes — bounded, not hung, and nothing invented. + let read = scripted_reader(vec![Err(std::io::ErrorKind::WouldBlock); 3]); + let buf = collect_replies(read, soon()); + assert!(buf.is_empty()); + } + + #[cfg(unix)] + #[test] + fn collect_replies_gives_up_on_a_real_read_error() { + // A genuine error (not WouldBlock) ends the wait early with whatever already arrived. + let start = std::time::Instant::now(); + let read = scripted_reader(vec![ + Ok(b"\x1b]11;rgb:1a1a/1a1a/1a1a\x1b\\"), + Err(std::io::ErrorKind::Other), + ]); + let buf = collect_replies(read, std::time::Instant::now() + Duration::from_secs(5)); + assert!(buf.starts_with(b"\x1b]11;")); + assert!( + start.elapsed() < Duration::from_secs(1), + "error must end the wait, not the deadline" + ); + } + #[test] fn da1_terminator_detected_only_when_complete() { assert!(has_da1_terminator(b"\x1b[?62;1;c")); From 66b018ccb3f426755d8dbefc0ef924b7467c59fe Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 23:15:57 -0400 Subject: [PATCH 058/203] test(review): PTY smoke for theme=auto probe responsiveness --- Cargo.lock | 1 + Makefile | 8 +- git-workon-review/Cargo.toml | 1 + git-workon-review/tests/pty_smoke.rs | 132 +++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 git-workon-review/tests/pty_smoke.rs diff --git a/Cargo.lock b/Cargo.lock index 40b9a93..6ee3119 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -964,6 +964,7 @@ dependencies = [ "clap", "clap_complete", "crossterm", + "expectrl", "git-workon-fixture", "git-workon-lib", "git2", diff --git a/Makefile b/Makefile index 2e19d17..316c8ab 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-dev install-man install-hooks build test fmt clippy +.PHONY: install install-dev install-man install-hooks build test smoke fmt clippy PREFIX ?= /usr/local @@ -25,6 +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. +smoke: + cargo test -p git-workon-review --test pty_smoke -- --ignored + fmt: cargo fmt diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index f8e4485..6c4e353 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -60,5 +60,6 @@ dist = false [dev-dependencies] assert_cmd.workspace = true assert_fs.workspace = true +expectrl.workspace = true git-workon-fixture.workspace = true predicates.workspace = true diff --git a/git-workon-review/tests/pty_smoke.rs b/git-workon-review/tests/pty_smoke.rs new file mode 100644 index 0000000..070b2a6 --- /dev/null +++ b/git-workon-review/tests/pty_smoke.rs @@ -0,0 +1,132 @@ +//! PTY smoke tests for the `theme = auto` terminal probe (dogfood round 2 regression). +//! +//! These spawn the real binary under a pseudo-terminal and play the *terminal's* side of the +//! OSC color-query conversation — the one scenario unit tests can't reach, and the one that hid +//! the round-2 wedge: a terminal that ANSWERS the probe. When the probe mishandled its replies +//! they leaked into crossterm as phantom keystrokes (`r` in `rgb:` fired refresh storms; `d` in +//! hex specs opened the discard confirm, which swallows every key but y/n/Esc), freezing startup +//! for ~30s. The assertion here is deliberately blunt: after startup settles, `q` must still +//! quit promptly. +//! +//! **Not run by default** (`#[ignore]`): PTY tests are wall-clock-bound (settle windows, probe +//! deadline) and load-sensitive — under heavy parallel CPU load a slow spawn can eat into the +//! responsiveness margin (same caveat as git-workon's `checkout_conflict_interactive_*` PTY +//! 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 +//! ``` +//! +//! 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)] + +use std::io::Write; +use std::time::{Duration, Instant}; + +use expectrl::{ + session::{OsProcess, OsStream}, + Expect, Session, +}; +use git_workon_fixture::prelude::*; + +/// How long `q` may take to terminate the app before we call startup unresponsive. Generous on +/// purpose: healthy is ~10ms, the regression was 30s–forever, and the slack absorbs CI load. +const RESPONSIVE: Duration = Duration::from_secs(5); + +/// A repo with one uncommitted change (so the TUI actually opens) and `theme = auto` (so the +/// probe runs). +fn auto_theme_fixture() -> Fixture { + FixtureBuilder::new() + .config("workon.review.theme", "auto") + .unstaged_file("file.txt", "a\nb\nc\n", "a\nCHANGED\nc\n") + .build() + .expect("fixture") +} + +/// Spawn the review binary in a PTY sized like a real terminal (an unsized PTY is 0×0 and +/// ratatui draws nothing), cwd'd into the fixture's worktree. +fn spawn_review(fixture: &Fixture) -> Session { + let repo = fixture.repo().expect("fixture repo"); + let workdir = repo.workdir().expect("fixture workdir").to_path_buf(); + + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_git-workon-review")); + cmd.current_dir(workdir).env("TERM", "xterm-256color"); + + let mut session = expectrl::Session::spawn(cmd).expect("spawn in PTY"); + session + .get_process_mut() + .set_window_size(120, 40) + .expect("size PTY"); + session.set_expect_timeout(Some(Duration::from_secs(15))); + session +} + +/// Play a well-behaved answering terminal: reply to all 16 `OSC 4` color queries plus +/// `OSC 11`/`OSC 10`, then the DA1 sentinel. The replies deliberately contain the poison bytes +/// of the round-2 wedge — `r`/`g`/`b` (refresh binding) and `d` hex digits (discard binding) — +/// so any regression that leaks them into crossterm trips the discard-confirm modal and fails +/// the responsiveness assertion below. +fn answer_probe(session: &mut Session) { + let mut replies = Vec::new(); + for n in 0..16 { + let level = n * 16; + replies.extend_from_slice( + format!("\x1b]4;{n};rgb:{level:02x}{level:02x}/2020/4040\x1b\\").as_bytes(), + ); + } + replies.extend_from_slice(b"\x1b]11;rgb:1a1a/1a1a/1a1a\x1b\\"); // background (dark) + replies.extend_from_slice(b"\x1b]10;rgb:d3d3/d0d0/c8c8\x1b\\"); // foreground ('d' poison) + replies.extend_from_slice(b"\x1b[?62;22c"); // DA1 reply — the probe's stop sentinel + session.write_all(&replies).expect("write probe replies"); + session.flush().expect("flush probe replies"); +} + +/// Wait for the TUI to be up (alternate screen entered), let any straggler reply bytes land, +/// then press `q` and require a prompt exit. +fn assert_q_quits_promptly(mut session: Session) { + session + .expect("\x1b[?1049h") // EnterAlternateScreen — tui::run has the terminal + .expect("TUI entered the alternate screen"); + + // Give leaked bytes (the regression case) time to reach crossterm before q, so a regressed + // binary deterministically has its discard-confirm modal up — and swallows the q. + std::thread::sleep(Duration::from_millis(500)); + + let pressed_q = Instant::now(); + session.send("q").expect("send q"); + session.expect(expectrl::Eof).expect("app exited on q"); + let latency = pressed_q.elapsed(); + assert!( + latency < RESPONSIVE, + "q took {latency:?} to quit the app — startup input is wedged" + ); +} + +#[test] +#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty_smoke -- --ignored"] +fn theme_auto_stays_responsive_when_the_terminal_answers() { + let fixture = auto_theme_fixture(); + let mut session = spawn_review(&fixture); + + // The probe's query burst ends with its DA1 request; seeing it means every OSC query has + // been written and the terminal may answer. + session + .expect("\x1b[c") + .expect("probe sent its query burst"); + answer_probe(&mut session); + + assert_q_quits_promptly(session); +} + +#[test] +#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty_smoke -- --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. + let fixture = auto_theme_fixture(); + let session = spawn_review(&fixture); + + assert_q_quits_promptly(session); +} From 84238e6223f98fe5f8bac7ef84123dd1132bf829 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 00:49:25 -0400 Subject: [PATCH 059/203] docs(review): lock M7 review-any-source design (ADR-030 + plan) --- CONTEXT.md | 10 ++ docs/adr/030-review-source-grammar.md | 97 ++++++++++++++++ docs/plans/review-any-source.md | 152 ++++++++++++++++++++++++++ docs/rfc/workon-review.md | 2 +- 4 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 docs/adr/030-review-source-grammar.md create mode 100644 docs/plans/review-any-source.md diff --git a/CONTEXT.md b/CONTEXT.md index f7899f9..9c4593d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,6 +12,16 @@ Terms used throughout the `git-workon` codebase. Implementation details do not b **Status filter** — a flag (`--dirty`, `--clean`, `--ahead`, `--behind`, `--gone`) that narrows a `list` or `find` result to worktrees in a specific state. Filters select **worktrees**: each check queries the working tree or branch-tracking state of a checked-out worktree. A metadata-only stack diff (`◯`) has no working tree and can never satisfy a status filter; it is excluded from any filtered result. See also: `StatusFilter`, `WorktreeDescriptor::is_dirty()`. +## Review + +**Changeset** — one reviewable unit in the review TUI: a node in a stack, a single inferred commit, or the uncommitted layer. Ordered base → head when part of a stack. See also: `workon::Changeset`. + +**Changeset span** — what a changeset covers: a resolved commit range (`base..head`) or the uncommitted working tree + index. _Avoid_: "changeset source" (renamed; "source" is the review-source concept below). + +**Review source** — the user's answer to "review *what?*": auto-detect (no argument), the `stack` keyword, the `uncommitted` keyword, a ref, a range, or a PR reference. Exact bare keywords win over same-named refs; a qualified spelling (`refs/heads/stack`) escapes. See also: [ADR-030](docs/adr/030-review-source-grammar.md). + +**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`. + ## 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/docs/adr/030-review-source-grammar.md b/docs/adr/030-review-source-grammar.md new file mode 100644 index 0000000..66bb5c4 --- /dev/null +++ b/docs/adr/030-review-source-grammar.md @@ -0,0 +1,97 @@ +# 030 — Review Source: One Sniffed Positional, Shape-Aware Resolution + +Status: accepted (2026-07-09, M7 design session) + +## Context + +Through M6.5 the review binary's `Cli` is empty: it reviews only what auto-detect finds +(the Graphite stack when one is active, else a single uncommitted changeset). M7 makes it +review *anything* — the RFC's "review any source" — which forces three intertwined +decisions: how a source is spelled on the command line, what changeset(s) each spelling +resolves to, and what happens when resolution fails. This is the binary's entire +user-facing argument surface, so it is expensive to re-shape once muscle memory forms. + +Alternatives considered for the spelling: subcommands (`review pr 123`, `review range a b`) +— unambiguous but verbose and unlike git's rev-positional idiom; flags (`--pr`, `--range`) +— noisiest for a daily-driver tool, and sources are mutually exclusive so flags fight. + +## Decision + +**Grammar — one optional sniffed positional.** `git workon review []`. No argument +keeps auto-detect unchanged. An argument is classified by precedence: + +1. **PR reference** — any form `workon`'s own default command accepts (`123` excluded; + `pr-123`, `#123`, `pr#123`, GitHub URLs), via git-workon-lib `parse_pr_reference`. +2. **Keyword** — exact bare `stack` or `uncommitted`. +3. **Range** — contains `..` or `...`. +4. **Ref** — everything else, resolved via rev-parse. + +**Keywords win; qualify to escape.** Classification happens before rev-parse, so +`review stack` is deterministic regardless of repo state. A branch literally named +`stack` is reviewable via any qualified spelling (`refs/heads/stack`, `heads/stack`) — +only the exact bare word matches the keyword. + +**`stack` keyword** — "give me the real stack": Graphite metadata when active, otherwise +git-inference (the lib's already-built `StackModel::Git` arm: one changeset per commit in +`upstream..HEAD`). No metadata and no upstream is a real error, never a silent fall-through +to uncommitted — an explicit ask deserves an explicit failure. This ships the M5-deferred +`StackModel::Git` wiring, scoped to the one keyword that means it. + +**`uncommitted` keyword** — always the single uncommitted changeset (M2–M4 behavior), +even in a Graphite repo. + +**`` — shape-aware dispatch.** Match what a person most plausibly means per shape: + +- *Graphite-tracked branch* → the whole stack focused at that branch + (`assemble_changesets` already does exactly this; outline and `]c` nav come along). +- *Untracked branch* → one committed changeset, base = merge-base(upstream if set, else + repo trunk, else error) — "what this branch adds". +- *Bare commit-ish* (sha, tag, `HEAD~2`) → one changeset spanning just that commit + (`parent..ref`). + +**Ranges — git-diff semantics, both dot forms.** `a..b` → base `a`, head `b` (endpoint +trees, exactly a committed span). `a...b` → base merge-base(a,b), head `b` (the PR-style +"what did b add since diverging"). An empty side defaults to `HEAD`. One committed +changeset either way; git-diff muscle memory transfers unchanged. + +**PR — gh metadata + fetch, one changeset.** Reuse git-workon-lib `pr.rs` end-to-end: +`fetch_pr_metadata` (gh CLI) for base/head/title/fork detection, `fetch_branch` for the +objects — no worktree is created; review is read-only. Changeset = +`merge-base(base, head)..head` (GitHub's own three-dot PR diff), PR title carried into the +changeset. Requires gh + network, like `workon #123` today. + +**Uncommitted layer only when focused on real HEAD.** The layer rides along exactly when +the thing under review is where the working tree actually is: `stack`, and `` where +ref is the current `HEAD` branch. Every other source — range, commit, PR, untracked +branch, a tracked branch you're not standing on — is committed-only. Rationale: +uncommitted changes diff against `HEAD`; the lib's unconditional insert-after-current +would attach them to a branch they don't belong to. + +**Failures surface before the TUI.** Unresolvable ref, bad range endpoint, missing gh, PR +fetch failure, no-upstream: pre-TUI miette errors naming the offending source text, with a +hint where one exists. Never enter the TUI on a broken source; never fall back to +auto-detect (silently reviewing the wrong thing after a typo is the one surprise a review +tool must not have). A valid-but-empty source keeps "nothing to review" + exit 0, extended +to name the source. + +**Completion — keywords + local branches + tags.** Offline git2 ref enumeration only; +after a `..`/`...` prefix, complete the right-hand ref the same way. No PR-number +completion (network in the TAB hot path). This is M6's deferred sub-delegation trigger: +git-workon's dynamic completer now shells out to `COMPLETE= git-workon-review` for +post-subcommand words. + +**Rename `ChangesetSource` → `ChangesetSpan`.** Its doc comment already says "what a +Changeset spans"; the rename frees "source" for the user-facing concept every roadmap +document already uses. Safe while the M1–M6.5 tower is unmerged. + +## Consequences + +- The review binary gains its first real argument; the `Source` enum + (Auto | Stack | Uncommitted | Ref | Range | Pr) becomes the seam between CLI parse and + changeset resolution. +- Stack assembly for a non-HEAD tracked branch must suppress the uncommitted layer — a + lib-side knob or an acquire-side filter (execution detail, see the M7 plan). +- `review ` resolves through the untracked-branch arm via its upstream (unpushed + commits) — an acceptable edge, not a special case. +- Git-inference changesets become reachable from the binary for the first time; its + per-commit semantics get real exposure. diff --git a/docs/plans/review-any-source.md b/docs/plans/review-any-source.md new file mode 100644 index 0000000..caf84b0 --- /dev/null +++ b/docs/plans/review-any-source.md @@ -0,0 +1,152 @@ +# Plan — Review Any Source (M7) + +Design locked 2026-07-09. Decisions live in **[ADR-030](../adr/030-review-source-grammar.md)** +(source grammar, per-shape resolution, error posture, completion scope, the +`ChangesetSpan` rename). This doc is the *execution* plan: what lands, in what order, how +each unit is verified. Read the ADR before implementing — this plan does not restate its +rationale. Glossary terms ("Review source", "Changeset span", "Uncommitted layer") are in +[CONTEXT.md](../../CONTEXT.md). + +Goal: `git workon review []` reviews *anything* — stack, uncommitted, ref, range, +PR — not just the auto-detected state. Read-only for committed sources (M5 semantics); +no-arg auto-detect behavior is byte-identical to today. + +## Scope (five tracks) + +1. **`ChangesetSpan` rename** — `workon::ChangesetSource` → `workon::ChangesetSpan` + (field `source` → `span`), mechanical across lib + review crates. +2. **Source classifier + keywords** — `Source` enum in the review crate; the binary's + `Cli` gains one optional positional (`[SOURCE]`); exact-bare-keyword precedence; + `stack` (Graphite → Git-inference → error) and `uncommitted` resolution; the + uncommitted-layer suppression seam in the lib. +3. **Rev sources** — `` shape-aware dispatch (tracked branch → focused stack; + untracked branch → merge-base changeset; commit-ish → single commit) and + `a..b` / `a...b` ranges (git-diff semantics, empty side = `HEAD`). +4. **PR source** — `parse_pr_reference` forms at top precedence; `fetch_pr_metadata` + + `fetch_branch` (fork-aware) → one committed changeset `merge-base(base,head)..head`, + PR title carried through. No worktree is created. +5. **Completion** — review-binary completer offers keywords + local branches + tags + (and the RHS after `..`/`...`); git-workon's completer sub-delegates post-subcommand + words to `COMPLETE= git-workon-review` (the M6-deferred shell-out). + +## Changeset partition (Graphite stack) + +Linear stack — each unit extends the classifier the previous one introduced. Base: +the current M3–M6.5 tower tip (`uc-roadmap-reprioritize`/`uc-pty-smoke`), or `main` once +the tower lands. Each unit is land-alone (green + valuable by itself) and +standalone-review (~≤400 non-mechanical lines). + +``` + + └─ m7-span-rename CS1 ── ChangesetSource → ChangesetSpan (mechanical) + └─ m7-source-keywords CS2 ── Source enum, positional arg, stack/uncommitted keywords + └─ m7-source-revs CS3 ── dispatch + ranges (grammar complete) + └─ m7-source-pr CS4 ── PR references via pr.rs + └─ m7-complete CS5 ── source completion + git-workon sub-delegation +``` + +Interim behavior is honest at every cut: before CS3, a ref/range argument fails the +keyword match and errors pre-TUI as an unresolvable source; before CS4, `pr-123` falls +through to the ref arm and errors the same way (named, hinted). + +## Per-changeset detail + +### CS1 — `m7-span-rename` (refactor, lib + review) + +- `refactor(lib): rename ChangesetSource to ChangesetSpan`. Type, `Changeset.source` + field → `Changeset.span`, doc comments, all use sites in `acquire.rs`/`app.rs`/tests. +- Purely mechanical; no behavior change. Verify: full workspace green, `grep -rn + ChangesetSource` returns nothing. + +### CS2 — `m7-source-keywords` (review crate + one lib seam) + +- New `source.rs` in the review lib: `Source` enum + (`Auto | Stack | Uncommitted | Ref(String) | Range{..} | Pr(PullRequest)`) with + `Source::classify(&str)` implementing the ADR precedence. In CS2 the classifier ships + with keyword + fallback-to-`Ref` arms only; `Ref` resolution errors as unresolvable + (real resolution is CS3). Classification is pure → unit-test exhaustively (keyword + exactness: `Stack` ≠ `stack` keyword? No — exact bare match is case-sensitive `stack`; + `refs/heads/stack` classifies as `Ref`). +- `Cli` gains `Option` positional `[SOURCE]`; `main.rs` routes + `None` → `Source::Auto` → existing `resolve_changesets` (unchanged path). +- `stack`: Graphite → `assemble_changesets(.., Graphite)`; else Git-inference + (`StackModel::Git` — first binary wiring); `NoUpstream` surfaces pre-TUI with a hint + (set an upstream, or `review uncommitted`). +- `uncommitted`: the single synthetic uncommitted changeset (extract today's + `resolve_changesets` fallback arm for reuse). +- **Lib seam**: `assemble_changesets` must be able to omit the uncommitted layer + (ADR-030: layer only when focused on real HEAD). Prefer an explicit parameter over a + post-filter — a post-filter must also repair the `current` flag, which is subtle. + CS2 introduces the seam (keywords always run with the layer *on*, since `stack` + reviews HEAD's stack); CS3 is the first caller that turns it off. +- New error variants in review `error.rs` per ADR-008 — **load `/docs errors` first**. +- Verify: fixture tests for both keyword resolutions in Graphite and plain-git repos + (sqlite + legacy metadata modes), error cases asserted with `NO_COLOR=1`. + +### CS3 — `m7-source-revs` (review crate + acquire) + +- `Ref` resolution, dispatched on shape (ADR-030): Graphite-tracked branch → + `assemble_changesets` focused there, uncommitted layer ON iff the ref is the actual + `HEAD` branch (first user of the CS2 lib seam); untracked branch → one committed + changeset, base = merge-base(upstream, else trunk, else error); commit-ish → + `parent..ref` (root commit: empty-tree base). +- `Range` resolution: split on `...` first, then `..`; empty side → `HEAD`; rev-parse + each endpoint; `...` → merge-base base. One committed changeset named after the + source text as typed. +- Empty-but-valid results extend the existing "nothing to review" to name the source. +- Verify: fixture matrix — tracked/untracked/commit/tag shapes; both dot forms; + `review ` == auto-detect output (layer present); reviewing a non-HEAD + tracked branch on a dirty tree asserts NO uncommitted layer and correct `current`. + +### CS4 — `m7-source-pr` (review crate, reuses lib `pr.rs`) + +- Classifier gains the PR arm at top precedence (`parse_pr_reference`; also accept + `pr-123` if the lib parser doesn't already — check first, extend the *lib parser* + if not, with its existing tests as the pattern). +- Resolution: `check_gh_available` → `fetch_pr_metadata` → `detect_pr_remote` / + `setup_fork_remote` → `fetch_branch` → merge-base(base, head) → one committed + changeset, `title` from PR metadata. Every failure pre-TUI, named, hinted. +- Verify: classification unit tests offline; resolution wiring behind the smallest + testable seam (metadata → changeset mapping fixture-tested with a local "remote"; + the gh-network path itself is exercised manually — record the manual check in the + changeset description). + +### CS5 — `m7-complete` (review crate + git-workon completer) + +- Review-binary completer: keywords + local branch names + tag names via git2 ref + enumeration; when the current word contains `..`/`...`, complete the RHS ref the + same way. Offline only; no PR numbers. +- git-workon side: the dynamic completer's external-subcommand arm shells out + `COMPLETE= git-workon-review -- ` for post-subcommand words + (M6 CS3 left this seam documented; remember `_CLAP_COMPLETE_INDEX`). +- Verify: completion integration tests per M6's pattern (`COMPLETE=` env protocol), + asserting keyword + ref candidates and the delegation path. + +## Traps / notes for the implementer + +- **Load `/docs testing` before any tests; `/docs errors` before error variants.** +- `FORCE_COLOR=3` is set in this environment — output-asserting tests pin `NO_COLOR=1`. +- Verify TUI behavior by instrumenting, never by grepping ratatui frames. +- The Git-inference arm (`assemble_git`) is lib-complete and lib-tested; CS2 only wires + it. Don't reimplement. +- `resolve_changesets`'s doc comment explains why auto-detect must NOT route plain-git + repos to `StackModel::Git` — that reasoning stays true; only the explicit `stack` + keyword takes the Git arm. +- Working-tree leftovers are user WIP — never stage `.claude/settings.json`, + `.claude/hooks/post-edit-rust.sh`, `docs/diagrams/agent-integration.md`, + `docs/recipes/agent-integration.md`. `git add` specific files, never `-A`/`-u`. +- Commits: Conventional, single line ≤72 chars, no body/footer. + +## Verification gates (green before any changeset is called done) + +```bash +NO_COLOR=1 cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo fmt --all -- --check +cargo run -p git-workon-review -- # manual: each source shape renders +``` + +## Acceptance (RFC M7) + +`git workon review ` / `` / `pr-123` renders the right changeset(s); +`git workon review ` completes sources. diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 53dc698..4a3781d 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -140,7 +140,7 @@ The remaining roadmap is resequenced around the tool being **the author's own ev - **Prerequisite — Land M3–M6.5** (process, parallel to features; not a numbered milestone). QA the unmerged M3→M6.5 tower → merge to `main` → reliable install (a local build on PATH is enough to dogfood; the [ADR-027](../adr/027-review-crate-workspace-placement.md) release/homebrew "M3 flip" is a deferrable sub-decision). Gates real daily use regardless of features. QA checklist in memory `review-tui-priority-everyday-use` (`theme=auto` responsiveness, `theme=light` canvas, committed-changeset nav). -- **M7 — review any source.** A source selector — `stack | uncommitted | | | pr-####` — so the tool reviews *anything*, not just the auto-detected stack/uncommitted state. **Ordered first:** it is the tool's core *read* identity, read-only (low-risk), independent of the write verbs, and the M1/M5 lib already provides `assemble_changesets` + the `diff_changeset` router — mostly source-arg parse → resolve to changeset(s) → existing pipeline. PR support reuses git-workon-lib's `pr.rs`. Also **completes M6's deferred completion sub-delegation** (its trigger was exactly this arg gaining completion-worthy values). Acceptance: `git workon review ` / `` / `pr-123` renders the right changeset(s); `git workon review ` completes sources. +- **M7 — review any source.** A source selector — `stack | uncommitted | | | pr-####` — so the tool reviews *anything*, not just the auto-detected stack/uncommitted state. **Ordered first:** it is the tool's core *read* identity, read-only (low-risk), independent of the write verbs, and the M1/M5 lib already provides `assemble_changesets` + the `diff_changeset` router — mostly source-arg parse → resolve to changeset(s) → existing pipeline. PR support reuses git-workon-lib's `pr.rs`. Also **completes M6's deferred completion sub-delegation** (its trigger was exactly this arg gaining completion-worthy values). Acceptance: `git workon review ` / `` / `pr-123` renders the right changeset(s); `git workon review ` completes sources. Design locked 2026-07-09 — [ADR-030](../adr/030-review-source-grammar.md) (one sniffed positional, keyword-over-ref precedence, shape-aware `` dispatch, git-diff dot semantics, gh-backed PR resolution, uncommitted-layer-on-HEAD-only, fail-before-TUI, offline completion, `ChangesetSource`→`ChangesetSpan` rename); execution plan `docs/plans/review-any-source.md` (five changesets `m7-span-rename → m7-source-keywords → m7-source-revs → m7-source-pr → m7-complete`). - **M8 — commit operations.** Commit the staged changes without leaving the TUI — message editor (inline vs `$EDITOR`), Conventional-Commit-aware (enforced by `git-hooks/commit-msg`); **amend** the current commit; **fixup/absorb** staged changes into an earlier changeset in the stack. Closes the review→stage→**commit** loop — the acute daily-driver gap. Acceptance: stage in the TUI, commit/amend/fixup, verified against real git. From dcd6b756820cf0f56dda0c8bc915065c270cf07b Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 01:18:12 -0400 Subject: [PATCH 060/203] refactor(lib): rename ChangesetSource to ChangesetSpan --- git-workon-lib/src/changeset.rs | 20 +++---- git-workon-lib/tests/suite/changeset.rs | 31 +++++------ git-workon-review/src/acquire.rs | 18 +++---- git-workon-review/src/app.rs | 72 ++++++++++++------------- git-workon-review/src/render.rs | 14 ++--- git-workon-review/src/tui.rs | 6 +-- git-workon-review/tests/diff_model.rs | 4 +- 7 files changed, 79 insertions(+), 86 deletions(-) diff --git a/git-workon-lib/src/changeset.rs b/git-workon-lib/src/changeset.rs index 6010a2f..31e2b1f 100644 --- a/git-workon-lib/src/changeset.rs +++ b/git-workon-lib/src/changeset.rs @@ -2,7 +2,7 @@ //! reviewable [`Changeset`]s for the worktree whose `HEAD` is a given branch. //! //! This is the substrate the review TUI (M2+) consumes. It stays **diff-free**: every -//! [`Changeset`] carries resolved `git2::Oid` rev pairs (or the [`ChangesetSource::Uncommitted`] +//! [`Changeset`] carries resolved `git2::Oid` rev pairs (or the [`ChangesetSpan::Uncommitted`] //! marker), never a parsed diff. Detecting uncommitted changes uses `repo.statuses`, never //! `repo.diff_*`. //! @@ -19,7 +19,7 @@ //! (oldest first), one [`Changeset`] per commit. //! //! In both metadata-bearing arms, a non-empty `repo.statuses` result inserts a -//! [`ChangesetSource::Uncommitted`] entry immediately after the current node, taking over +//! [`ChangesetSpan::Uncommitted`] entry immediately after the current node, taking over //! `current`. use std::collections::{HashMap, HashSet}; @@ -31,7 +31,7 @@ use crate::stack::{graphite, StackModel}; /// What a [`Changeset`] spans: a resolved commit range, or the working tree + index. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChangesetSource { +pub enum ChangesetSpan { /// A committed range `base..head` — resolved OIDs only; the lib never diffs them itself. Committed { base: Oid, head: Oid }, /// Uncommitted working-tree + index changes relative to the current branch's head. @@ -43,12 +43,12 @@ pub enum ChangesetSource { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Changeset { /// Branch name for stack nodes; 8-hex abbreviated commit id for git-inference per-commit - /// changesets; the current branch name for [`ChangesetSource::Uncommitted`]. + /// changesets; the current branch name for [`ChangesetSpan::Uncommitted`]. pub name: String, /// The commit range (or uncommitted marker) this changeset covers. - pub source: ChangesetSource, + pub span: ChangesetSpan, /// PR title (from `.graphite_pr_info`) for Graphite nodes; commit summary for - /// git-inference nodes; `None` for [`ChangesetSource::Uncommitted`]. + /// git-inference nodes; `None` for [`ChangesetSpan::Uncommitted`]. pub title: Option, /// Exactly one entry in the returned `Vec` is current: the Uncommitted entry when /// present, otherwise the current branch's node (Graphite) or tip commit (Git). @@ -213,7 +213,7 @@ fn assemble_graphite(repo: &Repository, head_branch: &str) -> Result Result> let base = commit.parent_id(0).unwrap_or(oid); changesets.push(Changeset { name: short_id(oid), - source: ChangesetSource::Committed { base, head: oid }, + span: ChangesetSpan::Committed { base, head: oid }, title: commit.summary()?.map(str::to_string), current: false, needs_restack: false, @@ -376,7 +376,7 @@ fn short_id(oid: Oid) -> String { oid.to_string()[..8].to_string() } -/// Insert a [`ChangesetSource::Uncommitted`] entry immediately after `current_index` (or at +/// Insert a [`ChangesetSpan::Uncommitted`] entry immediately after `current_index` (or at /// the end, if there is no committed current node) when `repo.statuses` reports any working /// tree or index changes. Demotes the previous current node's `current` flag. No-op on a /// clean tree. @@ -402,7 +402,7 @@ fn insert_uncommitted_layer( insert_at, Changeset { name: current_branch.to_string(), - source: ChangesetSource::Uncommitted, + span: ChangesetSpan::Uncommitted, title: None, current: true, needs_restack: false, diff --git a/git-workon-lib/tests/suite/changeset.rs b/git-workon-lib/tests/suite/changeset.rs index fc09004..2411529 100644 --- a/git-workon-lib/tests/suite/changeset.rs +++ b/git-workon-lib/tests/suite/changeset.rs @@ -1,7 +1,7 @@ use git_workon_fixture::prelude::*; use std::error::Error; use workon::{ - assemble_changesets, ChangesetError, ChangesetSource, StackError, StackModel, WorkonError, + assemble_changesets, ChangesetError, ChangesetSpan, StackError, StackModel, WorkonError, }; // ── both-format parameterization (see tests/stack.rs) ──────────────────────── @@ -64,8 +64,8 @@ fn graphite_linear_order_current_and_titles(format: MetadataFormat) -> Result<() assert_eq!(current, vec!["b"]); let b_cs = changesets.iter().find(|c| c.name == "b").unwrap(); - match b_cs.source { - ChangesetSource::Committed { base, head } => { + match b_cs.span { + ChangesetSpan::Committed { base, head } => { assert_eq!(base, a_tip, "b's base must be a's recorded parent tip"); assert_eq!(head, b_tip, "b's head must be its live tip"); } @@ -120,8 +120,8 @@ fn graphite_all_at_one_commit_base_equals_head( let changesets = assemble_changesets(repo, "a", StackModel::Graphite)?; assert_eq!(changesets.len(), 1); - match changesets[0].source { - ChangesetSource::Committed { base, head } => { + match changesets[0].span { + ChangesetSpan::Committed { base, head } => { assert_eq!(base, head, "no divergence yet: base must equal head") } _ => panic!("expected Committed"), @@ -216,8 +216,8 @@ fn trap7_spans_stale_branch_revision_to_live_head( let repo = fixture.repo()?; let changesets = assemble_changesets(repo, "feat-a", StackModel::Graphite)?; assert_eq!(changesets.len(), 1); - match changesets[0].source { - ChangesetSource::Committed { base, head } => { + match changesets[0].span { + ChangesetSpan::Committed { base, head } => { assert_eq!( base, main_tip, "base must be the recorded parentBranchRevision" @@ -340,8 +340,8 @@ fn needs_restack_false_with_empty_parent_revision_and_merge_base_fallback( let changesets = assemble_changesets(repo, "a", StackModel::Graphite)?; assert_eq!(changesets.len(), 1); assert!(!changesets[0].needs_restack); - match changesets[0].source { - ChangesetSource::Committed { base, head } => { + match changesets[0].span { + ChangesetSpan::Committed { base, head } => { assert_eq!(head, a_tip); assert_eq!(base, main_tip, "merge-base fallback resolves to main's tip"); } @@ -450,7 +450,7 @@ fn uncommitted_layer_absent_on_clean_tree(format: MetadataFormat) -> Result<(), let changesets = assemble_changesets(repo, "a", StackModel::Graphite)?; assert_eq!(changesets.len(), 1); assert!(changesets[0].current); - assert_ne!(changesets[0].source, ChangesetSource::Uncommitted); + assert_ne!(changesets[0].span, ChangesetSpan::Uncommitted); Ok(()) } both_formats!(uncommitted_layer_absent_on_clean_tree); @@ -464,7 +464,7 @@ fn assert_uncommitted_inserted_after_current( assert_eq!(changesets.len(), 2); assert_eq!(changesets[0].name, current_branch); assert!(!changesets[0].current, "branch node must drop current"); - assert_eq!(changesets[1].source, ChangesetSource::Uncommitted); + assert_eq!(changesets[1].span, ChangesetSpan::Uncommitted); assert_eq!(changesets[1].name, current_branch); assert!(changesets[1].current, "Uncommitted takes current"); Ok(()) @@ -541,11 +541,8 @@ fn git_inference_two_commits_oldest_first() -> Result<(), Box> { "name is an 8-hex abbreviated id" ); - match (&changesets[0].source, &changesets[1].source) { - ( - ChangesetSource::Committed { head: h0, .. }, - ChangesetSource::Committed { base: b1, .. }, - ) => { + match (&changesets[0].span, &changesets[1].span) { + (ChangesetSpan::Committed { head: h0, .. }, ChangesetSpan::Committed { base: b1, .. }) => { assert_eq!(*h0, first, "first commit's head is its own oid"); assert_eq!(*b1, *h0, "second's base is first's head"); } @@ -567,7 +564,7 @@ fn git_inference_dirty_tree_appends_uncommitted_as_current() -> Result<(), Box Result { }) } -/// Diff `base`'s tree against `head`'s tree, for a [`ChangesetSource::Committed`] changeset — +/// Diff `base`'s tree against `head`'s tree, for a [`ChangesetSpan::Committed`] changeset — /// rename/copy detection runs via [`git2::Diff::find_similar`] so renamed files come back as /// [`crate::model::FileStatus::Renamed`] instead of a delete+add pair. pub fn diff_committed(repo: &Repository, base: Oid, head: Oid) -> Result { @@ -91,25 +91,25 @@ pub fn diff_committed(repo: &Repository, base: Oid, head: Oid) -> Result Result { - match cs.source { - ChangesetSource::Committed { base, head } => diff_committed(repo, base, head) + match cs.span { + ChangesetSpan::Committed { base, head } => diff_committed(repo, base, head) .map(ChangesetDiff::Committed) .map_err(|err| changeset_diff_failed(&cs.name, err)), - ChangesetSource::Uncommitted => diff_uncommitted(repo) + ChangesetSpan::Uncommitted => diff_uncommitted(repo) .map(ChangesetDiff::Uncommitted) .map_err(|err| changeset_diff_failed(&cs.name, err)), } @@ -141,7 +141,7 @@ pub fn resolve_changesets( )?), StackModel::None | StackModel::Git => Ok(vec![Changeset { name: head_branch.to_string(), - source: ChangesetSource::Uncommitted, + span: ChangesetSpan::Uncommitted, title: None, current: true, needs_restack: false, diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index fb57e31..3783858 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -13,7 +13,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::Path; use git2::Repository; -use workon::{Changeset, ChangesetSource}; +use workon::{Changeset, ChangesetSpan}; use crate::acquire::{ChangesetDiff, WorktreeDiffs}; use crate::align::{align_file, collapse_gaps, inline_rows, CellKind, DisplayRow, InlineRow, Row}; @@ -312,12 +312,10 @@ impl FileView { /// not all of `App` — a `&self` method here would make the borrow checker treat the tree as /// blocking every OTHER field access (e.g. `&mut self.highlighter`) for its whole lifetime, even /// though the two never actually conflict. -fn old_side_tree_for(repo: &Repository, source: ChangesetSource) -> Option> { - match source { - ChangesetSource::Committed { base, .. } => { - repo.find_commit(base).and_then(|c| c.tree()).ok() - } - ChangesetSource::Uncommitted => repo.head().and_then(|h| h.peel_to_tree()).ok(), +fn old_side_tree_for(repo: &Repository, span: ChangesetSpan) -> Option> { + match span { + ChangesetSpan::Committed { base, .. } => repo.find_commit(base).and_then(|c| c.tree()).ok(), + ChangesetSpan::Uncommitted => repo.head().and_then(|h| h.peel_to_tree()).ok(), } } @@ -330,12 +328,10 @@ fn old_side_tree_for(repo: &Repository, source: ChangesetSource) -> Option Option> { - match source { - ChangesetSource::Committed { head, .. } => { - repo.find_commit(head).and_then(|c| c.tree()).ok() - } - ChangesetSource::Uncommitted => None, +fn new_side_tree_for(repo: &Repository, span: ChangesetSpan) -> Option> { + match span { + ChangesetSpan::Committed { head, .. } => repo.find_commit(head).and_then(|c| c.tree()).ok(), + ChangesetSpan::Uncommitted => None, } } @@ -806,7 +802,7 @@ impl App { .unwrap_or_default(); let cs = Changeset { name, - source: ChangesetSource::Uncommitted, + span: ChangesetSpan::Uncommitted, title: None, current: true, needs_restack: false, @@ -966,12 +962,12 @@ impl App { } /// Whether the ACTIVE changeset is a committed range (`base..head`) rather than the - /// uncommitted worktree layer — derived from [`workon::ChangesetSource`] on every call rather + /// uncommitted worktree layer — derived from [`workon::ChangesetSpan`] on every call rather /// than cached (locked decision #2's "derive, don't store" mode gate). Drives every /// committed-mode guard: the mode-aware staging refusal, skipping combined attribution (no /// staged/unstaged sets exist to color by), and locking zoom to combined. pub fn is_committed(&self) -> bool { - matches!(self.cur().cs.source, ChangesetSource::Committed { .. }) + matches!(self.cur().cs.span, ChangesetSpan::Committed { .. }) } /// Re-run [`crate::acquire::resolve_changesets`] against the CURRENT `HEAD` branch and @@ -1256,18 +1252,18 @@ impl App { // Combined role. // Re-peeled per call rather than cached on `App`: for the uncommitted layer `HEAD` can // move between file loads, and the tree is cheap to re-peel either way. - // `self.cur().cs.source` is `Copy`, so reading it here borrows `self` only for this + // `self.cur().cs.span` is `Copy`, so reading it here borrows `self` only for this // sub-expression — `head_tree` itself ends up borrowing `self.repo` alone (via the free // `old_side_tree_for`), leaving `&mut self.highlighter` free below. A method tied to // `&self` would instead have bound the tree's lifetime to all of `self`. - let Some(head_tree) = old_side_tree_for(&self.repo, self.cur().cs.source) else { + let Some(head_tree) = old_side_tree_for(&self.repo, self.cur().cs.span) else { return; }; // New-side source mirrors the old side: `None` (worktree) for the uncommitted layer, // the changeset's `head` tree for a committed changeset. Same free-fn borrow dance as // `old_side_tree_for` — both trees borrow only `self.repo`, so `&mut self.highlighter` // stays free for `FileView::load`. - let new_tree = new_side_tree_for(&self.repo, self.cur().cs.source); + let new_tree = new_side_tree_for(&self.repo, self.cur().cs.span); let file = self.cur().diff.files[idx].clone(); let view = FileView::load( &self.repo, @@ -2478,12 +2474,12 @@ fn current_cs_index(changesets: &[ChangesetView]) -> usize { /// base rev (7-char short-sha), or `"HEAD"` for the uncommitted layer (worktree ↔ `HEAD`, /// unchanged from M2–M4). fn base_label_for(cs: &Changeset) -> String { - match cs.source { - ChangesetSource::Committed { base, .. } => { + match cs.span { + ChangesetSpan::Committed { base, .. } => { let full = base.to_string(); full.chars().take(7).collect() } - ChangesetSource::Uncommitted => "HEAD".to_string(), + ChangesetSpan::Uncommitted => "HEAD".to_string(), } } @@ -2629,7 +2625,7 @@ pub(crate) mod test_support { mod tests { use git2::Repository; use git_workon_fixture::prelude::*; - use workon::{Changeset, ChangesetSource}; + use workon::{Changeset, ChangesetSpan}; use super::test_support::app_from_fixture; use super::{ @@ -4482,8 +4478,8 @@ mod tests { assert_eq!(app.current_cs(), 0); assert_eq!(app.base_label, "HEAD"); assert!(matches!( - app.current_changeset().source, - ChangesetSource::Uncommitted + app.current_changeset().span, + ChangesetSpan::Uncommitted )); } @@ -4507,7 +4503,7 @@ mod tests { let repo = fixture.repo().unwrap(); let cs = Changeset { name: "main".to_string(), - source: ChangesetSource::Committed { base, head }, + span: ChangesetSpan::Committed { base, head }, title: None, current: true, needs_restack: false, @@ -4563,7 +4559,7 @@ mod tests { let repo = fixture.repo().unwrap(); let cs = Changeset { name: "main".to_string(), - source: ChangesetSource::Committed { base, head }, + span: ChangesetSpan::Committed { base, head }, title: None, current: true, needs_restack: false, @@ -4598,14 +4594,14 @@ mod tests { // Deliberately NOT current — listed first, so a naive "open index 0" would pick it. let not_current = Changeset { name: "not-current".to_string(), - source: ChangesetSource::Committed { base, head: base }, + span: ChangesetSpan::Committed { base, head: base }, title: None, current: false, needs_restack: false, }; let current = Changeset { name: "current".to_string(), - source: ChangesetSource::Committed { base, head }, + span: ChangesetSpan::Committed { base, head }, title: None, current: true, needs_restack: false, @@ -4663,7 +4659,7 @@ mod tests { let cs_a = Changeset { name: "cs-a".to_string(), - source: ChangesetSource::Committed { + span: ChangesetSpan::Committed { base: root, head: mid, }, @@ -4673,7 +4669,7 @@ mod tests { }; let cs_b = Changeset { name: "cs-b".to_string(), - source: ChangesetSource::Committed { base: mid, head }, + span: ChangesetSpan::Committed { base: mid, head }, title: None, current: false, needs_restack: false, @@ -4797,7 +4793,7 @@ mod tests { let cs_a = Changeset { name: "cs-a".to_string(), - source: ChangesetSource::Committed { + span: ChangesetSpan::Committed { base: root, head: mid, }, @@ -4807,7 +4803,7 @@ mod tests { }; let cs_b = Changeset { name: "cs-b".to_string(), - source: ChangesetSource::Committed { base: mid, head }, + span: ChangesetSpan::Committed { base: mid, head }, title: None, current: true, needs_restack: false, @@ -4977,14 +4973,14 @@ mod tests { let committed = Changeset { name: "committed".to_string(), - source: ChangesetSource::Committed { base, head }, + span: ChangesetSpan::Committed { base, head }, title: Some("Committed work".to_string()), current: false, needs_restack: false, }; let uncommitted = Changeset { name: "uncommitted".to_string(), - source: ChangesetSource::Uncommitted, + span: ChangesetSpan::Uncommitted, title: None, current: true, needs_restack: false, @@ -5120,7 +5116,7 @@ mod tests { let cs_a = Changeset { name: "cs-a".to_string(), - source: ChangesetSource::Committed { + span: ChangesetSpan::Committed { base: root, head: mid, }, @@ -5130,7 +5126,7 @@ mod tests { }; let cs_b = Changeset { name: "cs-b".to_string(), - source: ChangesetSource::Committed { base: mid, head }, + span: ChangesetSpan::Committed { base: mid, head }, title: None, current: true, needs_restack: true, @@ -5351,7 +5347,7 @@ mod tests { let cs = Changeset { name: "cs".to_string(), - source: ChangesetSource::Committed { base: root, head }, + span: ChangesetSpan::Committed { base: root, head }, title: None, current: true, needs_restack: false, diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index f7cf9b9..b062b50 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -1988,7 +1988,7 @@ mod tests { /// (`mid..head`, one file, `current` + `needs_restack`). fn two_committed_changesets_app(fixture: &Fixture) -> App { use git2::Repository; - use workon::{Changeset, ChangesetSource}; + use workon::{Changeset, ChangesetSpan}; use crate::app::ChangesetView; @@ -2011,7 +2011,7 @@ mod tests { let cs_a = Changeset { name: "cs-a".to_string(), - source: ChangesetSource::Committed { + span: ChangesetSpan::Committed { base: root, head: mid, }, @@ -2021,7 +2021,7 @@ mod tests { }; let cs_b = Changeset { name: "cs-b".to_string(), - source: ChangesetSource::Committed { base: mid, head }, + span: ChangesetSpan::Committed { base: mid, head }, title: None, current: true, needs_restack: true, @@ -2141,7 +2141,7 @@ mod tests { // anything, it's a committed range. Assert the fix: the Add side renders the plain // (bright) pair. use git2::Repository; - use workon::{Changeset, ChangesetSource}; + use workon::{Changeset, ChangesetSpan}; use crate::app::ChangesetView; @@ -2165,7 +2165,7 @@ mod tests { let cs = Changeset { name: "main".to_string(), - source: ChangesetSource::Committed { base, head }, + span: ChangesetSpan::Committed { base, head }, title: None, current: true, needs_restack: false, @@ -2382,7 +2382,7 @@ mod tests { /// deliberately flat and never produce a directory row. fn changeset_with_nested_paths(fixture: &Fixture) -> App { use git2::Repository; - use workon::{Changeset, ChangesetSource}; + use workon::{Changeset, ChangesetSpan}; use crate::app::ChangesetView; @@ -2401,7 +2401,7 @@ mod tests { let cs = Changeset { name: "cs".to_string(), - source: ChangesetSource::Committed { base: root, head }, + span: ChangesetSpan::Committed { base: root, head }, title: None, current: true, needs_restack: false, diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 72ae14b..e20e646 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -809,7 +809,7 @@ mod tests { /// `app_from_fixture`'s doc comment above for why the helpers can't be shared directly). fn two_committed_changesets_app(fixture: &git_workon_fixture::fixture::Fixture) -> App { use git2::Repository; - use workon::{Changeset, ChangesetSource}; + use workon::{Changeset, ChangesetSpan}; use workon_review::acquire::diff_changeset; use workon_review::app::ChangesetView; @@ -832,7 +832,7 @@ mod tests { let cs_a = Changeset { name: "cs-a".to_string(), - source: ChangesetSource::Committed { + span: ChangesetSpan::Committed { base: root, head: mid, }, @@ -842,7 +842,7 @@ mod tests { }; let cs_b = Changeset { name: "cs-b".to_string(), - source: ChangesetSource::Committed { base: mid, head }, + span: ChangesetSpan::Committed { base: mid, head }, title: None, current: true, needs_restack: false, diff --git a/git-workon-review/tests/diff_model.rs b/git-workon-review/tests/diff_model.rs index ac08167..c12df97 100644 --- a/git-workon-review/tests/diff_model.rs +++ b/git-workon-review/tests/diff_model.rs @@ -7,7 +7,7 @@ use git2::{BranchType, Oid, Repository}; use git_workon_fixture::prelude::*; -use workon::{assemble_changesets, Changeset, ChangesetSource, StackModel}; +use workon::{assemble_changesets, Changeset, ChangesetSpan, StackModel}; use workon_review::acquire::{diff_changeset, diff_committed, diff_uncommitted, ChangesetDiff}; use workon_review::error::DiffError; use workon_review::model::{FileStatus, LineKind}; @@ -476,7 +476,7 @@ fn diff_changeset_with_bad_base_oid_fails_never_empty() -> Result<(), Box Date: Thu, 9 Jul 2026 01:38:14 -0400 Subject: [PATCH 061/203] feat(review): source arg with stack and uncommitted keywords --- git-workon-lib/src/changeset.rs | 53 +++++++-- git-workon-lib/tests/suite/changeset.rs | 106 +++++++++++++---- git-workon-review/src/acquire.rs | 25 ++-- git-workon-review/src/error.rs | 42 +++++++ git-workon-review/src/lib.rs | 1 + git-workon-review/src/main.rs | 24 +++- git-workon-review/src/source.rs | 150 ++++++++++++++++++++++++ git-workon-review/tests/diff_model.rs | 5 +- git-workon-review/tests/source.rs | 128 ++++++++++++++++++++ 9 files changed, 486 insertions(+), 48 deletions(-) create mode 100644 git-workon-review/src/source.rs create mode 100644 git-workon-review/tests/source.rs diff --git a/git-workon-lib/src/changeset.rs b/git-workon-lib/src/changeset.rs index 31e2b1f..bb69fe0 100644 --- a/git-workon-lib/src/changeset.rs +++ b/git-workon-lib/src/changeset.rs @@ -20,7 +20,11 @@ //! //! In both metadata-bearing arms, a non-empty `repo.statuses` result inserts a //! [`ChangesetSpan::Uncommitted`] entry immediately after the current node, taking over -//! `current`. +//! `current` — but only when the caller passes [`UncommittedLayer::Include`]. The layer +//! belongs only when the thing under review is where the working tree actually is; a caller +//! resolving a source that isn't real `HEAD` (a range, a commit, a PR, a tracked branch you're +//! not standing on) passes [`UncommittedLayer::Omit`] instead. See [`UncommittedLayer`]'s own +//! doc for the full rationale. use std::collections::{HashMap, HashSet}; @@ -58,33 +62,56 @@ pub struct Changeset { pub needs_restack: bool, } +/// Whether [`assemble_changesets`] should insert the synthetic [`ChangesetSpan::Uncommitted`] +/// layer when the worktree has a dirty tree (see [`insert_uncommitted_layer`]). +/// +/// ADR-030: the layer only belongs when the thing under review is where the working tree +/// actually is (`stack`, or a `` that is the current `HEAD` branch) — every other source +/// (a range, a commit, a PR, an untracked branch, a tracked branch you're not standing on) is +/// committed-only, since uncommitted changes diff against `HEAD` and would otherwise attach to +/// a branch they don't belong to. An explicit parameter, not a post-filter: a post-filter would +/// also have to repair whichever node's `current` flag the inserted layer took over. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UncommittedLayer { + /// Insert the layer when the tree is dirty (today's behavior). + Include, + /// Never insert the layer, regardless of tree state. + Omit, +} + /// Assemble the ordered (base → head) changesets for the worktree whose `HEAD` is /// `head_branch`, under the given [`StackModel`]. /// /// See the module docs for the per-model walk semantics. Errors distinguish a genuinely /// broken reference or stack-metadata snapshot (bad ref, unresolvable recorded revision, no /// upstream) from a valid empty result (`Ok(vec![])`, e.g. a trunk-only worktree under `Git` -/// with a clean tree). +/// with a clean tree). `uncommitted` controls whether a dirty tree gets the synthetic +/// [`ChangesetSpan::Uncommitted`] layer at all — see [`UncommittedLayer`]. pub fn assemble_changesets( repo: &Repository, head_branch: &str, model: StackModel, + uncommitted: UncommittedLayer, ) -> Result> { match model { StackModel::None => Ok(vec![]), - StackModel::Git => assemble_git(repo, head_branch), - StackModel::Graphite => assemble_graphite(repo, head_branch), + StackModel::Git => assemble_git(repo, head_branch, uncommitted), + StackModel::Graphite => assemble_graphite(repo, head_branch, uncommitted), } } /// Graphite-metadata-driven assembly (see module docs for the walk). -fn assemble_graphite(repo: &Repository, head_branch: &str) -> Result> { +fn assemble_graphite( + repo: &Repository, + head_branch: &str, + uncommitted: UncommittedLayer, +) -> Result> { let metadata = graphite::read_branch_metadata(repo)?; let trunks: HashSet = graphite::read_trunks(repo).into_iter().collect(); // Trunk or untracked head_branch: no stack metadata to walk, fall back to git-inference. if trunks.contains(head_branch) || !metadata.contains_key(head_branch) { - return assemble_git(repo, head_branch); + return assemble_git(repo, head_branch, uncommitted); } // head_branch is tracked but its own branch ref is gone: a genuinely broken state, distinct @@ -223,7 +250,9 @@ fn assemble_graphite(repo: &Repository, head_branch: &str) -> Result Result> { +fn assemble_git( + repo: &Repository, + head_branch: &str, + uncommitted: UncommittedLayer, +) -> Result> { let branch = repo.find_branch(head_branch, BranchType::Local)?; let upstream = branch.upstream().map_err(|_| ChangesetError::NoUpstream { branch: head_branch.to_string(), @@ -366,7 +399,9 @@ fn assemble_git(repo: &Repository, head_branch: &str) -> Result> Some(last) }; - insert_uncommitted_layer(repo, head_branch, current_index, &mut changesets)?; + if uncommitted == UncommittedLayer::Include { + insert_uncommitted_layer(repo, head_branch, current_index, &mut changesets)?; + } Ok(changesets) } diff --git a/git-workon-lib/tests/suite/changeset.rs b/git-workon-lib/tests/suite/changeset.rs index 2411529..5b78fbe 100644 --- a/git-workon-lib/tests/suite/changeset.rs +++ b/git-workon-lib/tests/suite/changeset.rs @@ -1,7 +1,8 @@ use git_workon_fixture::prelude::*; use std::error::Error; use workon::{ - assemble_changesets, ChangesetError, ChangesetSpan, StackError, StackModel, WorkonError, + assemble_changesets, ChangesetError, ChangesetSpan, StackError, StackModel, UncommittedLayer, + WorkonError, }; // ── both-format parameterization (see tests/stack.rs) ──────────────────────── @@ -52,7 +53,8 @@ fn graphite_linear_order_current_and_titles(format: MetadataFormat) -> Result<() let b_tip = branch_tip(&fixture, "b")?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "b", StackModel::Graphite)?; + let changesets = + assemble_changesets(repo, "b", StackModel::Graphite, UncommittedLayer::Include)?; let names: Vec<&str> = changesets.iter().map(|c| c.name.as_str()).collect(); assert_eq!(names, vec!["a", "b", "c"]); @@ -100,7 +102,8 @@ fn graphite_fork_siblings_sorted_lexically(format: MetadataFormat) -> Result<(), .build()?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "a", StackModel::Graphite)?; + let changesets = + assemble_changesets(repo, "a", StackModel::Graphite, UncommittedLayer::Include)?; let names: Vec<&str> = changesets.iter().map(|c| c.name.as_str()).collect(); // Descendant DFS sorts siblings lexically, not by creation order (zeta was added first). assert_eq!(names, vec!["a", "alpha", "zeta"]); @@ -118,7 +121,8 @@ fn graphite_all_at_one_commit_base_equals_head( .build()?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "a", StackModel::Graphite)?; + let changesets = + assemble_changesets(repo, "a", StackModel::Graphite, UncommittedLayer::Include)?; assert_eq!(changesets.len(), 1); match changesets[0].span { ChangesetSpan::Committed { base, head } => { @@ -141,7 +145,12 @@ fn graphite_ghost_mid_stack_skipped_children_present( .build()?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "child", StackModel::Graphite)?; + let changesets = assemble_changesets( + repo, + "child", + StackModel::Graphite, + UncommittedLayer::Include, + )?; let names: Vec<&str> = changesets.iter().map(|c| c.name.as_str()).collect(); assert_eq!(names, vec!["child"], "ghost must not appear in output"); assert!(changesets[0].current); @@ -163,7 +172,12 @@ fn graphite_untracked_parent_excluded_from_walk( .build()?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "feat", StackModel::Graphite)?; + let changesets = assemble_changesets( + repo, + "feat", + StackModel::Graphite, + UncommittedLayer::Include, + )?; let names: Vec<&str> = changesets.iter().map(|c| c.name.as_str()).collect(); assert_eq!(names, vec!["feat"], "untracked parent must not be emitted"); Ok(()) @@ -180,7 +194,8 @@ fn graphite_current_branch_missing_ref_errors( .build()?; let repo = fixture.repo()?; - let err = assemble_changesets(repo, "c", StackModel::Graphite).unwrap_err(); + let err = assemble_changesets(repo, "c", StackModel::Graphite, UncommittedLayer::Include) + .unwrap_err(); match err { WorkonError::Changeset(ChangesetError::UnresolvableBranch { branch }) => { assert_eq!(branch, "c") @@ -214,7 +229,12 @@ fn trap7_spans_stale_branch_revision_to_live_head( .create("commit2")?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "feat-a", StackModel::Graphite)?; + let changesets = assemble_changesets( + repo, + "feat-a", + StackModel::Graphite, + UncommittedLayer::Include, + )?; assert_eq!(changesets.len(), 1); match changesets[0].span { ChangesetSpan::Committed { base, head } => { @@ -242,7 +262,13 @@ fn trap7_bogus_parent_revision_errors(format: MetadataFormat) -> Result<(), Box< .build()?; let repo = fixture.repo()?; - let err = assemble_changesets(repo, "feat-a", StackModel::Graphite).unwrap_err(); + let err = assemble_changesets( + repo, + "feat-a", + StackModel::Graphite, + UncommittedLayer::Include, + ) + .unwrap_err(); match err { WorkonError::Changeset(ChangesetError::InvalidParentRevision { branch, revision }) => { assert_eq!(branch, "feat-a"); @@ -266,7 +292,13 @@ fn trap7_corrupt_sqlite_db_errors() -> Result<(), Box> { let db_path = repo.commondir().join(".graphite_metadata.db"); std::fs::write(&db_path, b"not a sqlite database")?; - let err = assemble_changesets(repo, "feat-a", StackModel::Graphite).unwrap_err(); + let err = assemble_changesets( + repo, + "feat-a", + StackModel::Graphite, + UncommittedLayer::Include, + ) + .unwrap_err(); assert!( matches!(err, WorkonError::Stack(StackError::GtParseFailed { .. })), "expected GtParseFailed, got {err:?}" @@ -293,7 +325,12 @@ fn needs_restack_true_when_parent_advances_post_build( .create("advance parent")?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "child", StackModel::Graphite)?; + let changesets = assemble_changesets( + repo, + "child", + StackModel::Graphite, + UncommittedLayer::Include, + )?; let child_cs = changesets.iter().find(|c| c.name == "child").unwrap(); assert!( @@ -316,7 +353,8 @@ fn needs_restack_false_for_untouched_stack(format: MetadataFormat) -> Result<(), let fixture = linear_chain(format)?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "c", StackModel::Graphite)?; + let changesets = + assemble_changesets(repo, "c", StackModel::Graphite, UncommittedLayer::Include)?; assert!( changesets.iter().all(|c| !c.needs_restack), "no branch advanced past what metadata recorded" @@ -337,7 +375,8 @@ fn needs_restack_false_with_empty_parent_revision_and_merge_base_fallback( let a_tip = branch_tip(&fixture, "a")?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "a", StackModel::Graphite)?; + let changesets = + assemble_changesets(repo, "a", StackModel::Graphite, UncommittedLayer::Include)?; assert_eq!(changesets.len(), 1); assert!(!changesets[0].needs_restack); match changesets[0].span { @@ -370,7 +409,8 @@ fn needs_restack_computed_for_ancestors_of_current( .create("advance main")?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "b", StackModel::Graphite)?; + let changesets = + assemble_changesets(repo, "b", StackModel::Graphite, UncommittedLayer::Include)?; let a_cs = changesets.iter().find(|c| c.name == "a").unwrap(); assert!( @@ -447,7 +487,8 @@ fn uncommitted_layer_absent_on_clean_tree(format: MetadataFormat) -> Result<(), .build()?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "a", StackModel::Graphite)?; + let changesets = + assemble_changesets(repo, "a", StackModel::Graphite, UncommittedLayer::Include)?; assert_eq!(changesets.len(), 1); assert!(changesets[0].current); assert_ne!(changesets[0].span, ChangesetSpan::Uncommitted); @@ -460,7 +501,12 @@ fn assert_uncommitted_inserted_after_current( current_branch: &str, ) -> Result<(), Box> { let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, current_branch, StackModel::Graphite)?; + let changesets = assemble_changesets( + repo, + current_branch, + StackModel::Graphite, + UncommittedLayer::Include, + )?; assert_eq!(changesets.len(), 2); assert_eq!(changesets[0].name, current_branch); assert!(!changesets[0].current, "branch node must drop current"); @@ -485,7 +531,12 @@ fn graphite_falls_back_to_git_on_trunk(format: MetadataFormat) -> Result<(), Box .create("only commit")?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "main", StackModel::Graphite)?; + let changesets = assemble_changesets( + repo, + "main", + StackModel::Graphite, + UncommittedLayer::Include, + )?; assert_eq!(changesets.len(), 1); assert_eq!(changesets[0].title.as_deref(), Some("only commit")); Ok(()) @@ -508,7 +559,12 @@ fn graphite_falls_back_to_git_on_untracked_branch( .create("untracked commit")?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "feat-a", StackModel::Graphite)?; + let changesets = assemble_changesets( + repo, + "feat-a", + StackModel::Graphite, + UncommittedLayer::Include, + )?; assert_eq!(changesets.len(), 1); assert_eq!(changesets[0].title.as_deref(), Some("untracked commit")); Ok(()) @@ -528,7 +584,7 @@ fn git_inference_two_commits_oldest_first() -> Result<(), Box> { fixture.commit("main").file("b.txt", "2").create("second")?; let repo = fixture.repo()?; - let changesets = assemble_changesets(repo, "main", StackModel::Git)?; + let changesets = assemble_changesets(repo, "main", StackModel::Git, UncommittedLayer::Include)?; assert_eq!(changesets.len(), 2); assert_eq!(changesets[0].title.as_deref(), Some("first")); assert_eq!(changesets[1].title.as_deref(), Some("second")); @@ -561,7 +617,7 @@ fn git_inference_dirty_tree_appends_uncommitted_as_current() -> Result<(), Box Result<(), Box Result<(), Box> { let fixture = FixtureBuilder::new().build()?; let repo = fixture.repo()?; - let err = assemble_changesets(repo, "main", StackModel::Git).unwrap_err(); + let err = + assemble_changesets(repo, "main", StackModel::Git, UncommittedLayer::Include).unwrap_err(); match err { WorkonError::Changeset(ChangesetError::NoUpstream { branch }) => { assert_eq!(branch, "main") @@ -604,6 +661,9 @@ fn none_model_always_returns_empty() -> Result<(), Box> { let fixture = linear_chain(MetadataFormat::Refs)?; let repo = fixture.repo()?; - assert_eq!(assemble_changesets(repo, "c", StackModel::None)?, vec![]); + assert_eq!( + assemble_changesets(repo, "c", StackModel::None, UncommittedLayer::Include)?, + vec![] + ); Ok(()) } diff --git a/git-workon-review/src/acquire.rs b/git-workon-review/src/acquire.rs index 8093201..bc29d0a 100644 --- a/git-workon-review/src/acquire.rs +++ b/git-workon-review/src/acquire.rs @@ -6,7 +6,7 @@ //! git2 diffs and then a [`DiffModel`]. use git2::{DiffFindOptions, DiffOptions, Oid, Repository}; -use workon::{assemble_changesets, Changeset, ChangesetSpan, StackModel}; +use workon::{assemble_changesets, Changeset, ChangesetSpan, StackModel, UncommittedLayer}; use crate::error::DiffError; use crate::model::DiffModel; @@ -138,14 +138,23 @@ pub fn resolve_changesets( repo, head_branch, StackModel::Graphite, + UncommittedLayer::Include, )?), - StackModel::None | StackModel::Git => Ok(vec![Changeset { - name: head_branch.to_string(), - span: ChangesetSpan::Uncommitted, - title: None, - current: true, - needs_restack: false, - }]), + StackModel::None | StackModel::Git => Ok(vec![uncommitted_changeset(head_branch)]), + } +} + +/// The single synthetic [`ChangesetSpan::Uncommitted`] changeset for `head_branch` — always +/// `current`, no title, no restack question. Shared by [`resolve_changesets`]'s non-Graphite +/// fallback arm and the review binary's `uncommitted` keyword (`crate::source`), both of which +/// mean the same thing: "just diff the worktree." +pub fn uncommitted_changeset(head_branch: &str) -> Changeset { + Changeset { + name: head_branch.to_string(), + span: ChangesetSpan::Uncommitted, + title: None, + current: true, + needs_restack: false, } } diff --git a/git-workon-review/src/error.rs b/git-workon-review/src/error.rs index 7fa8865..300d90c 100644 --- a/git-workon-review/src/error.rs +++ b/git-workon-review/src/error.rs @@ -28,6 +28,11 @@ pub enum ReviewError { #[error(transparent)] #[diagnostic(transparent)] Apply(#[from] ApplyError), + + /// A `git workon review ` argument failed to resolve to changesets + #[error(transparent)] + #[diagnostic(transparent)] + Source(#[from] SourceError), } /// Errors building a [`crate::model::DiffModel`] from git2 structures, or acquiring one for a @@ -118,3 +123,40 @@ pub enum ApplyError { source: std::io::Error, }, } + +/// Errors resolving a `git workon review ` positional argument to changesets +/// (ADR-030: the classifier/resolver seam is [`crate::source::Source`]). +#[derive(Error, Diagnostic, Debug)] +pub enum SourceError { + /// The `stack` keyword found no Graphite metadata and `branch` has no upstream to infer a + /// git-only stack from. An explicit ask deserves an explicit failure — never a silent + /// fall-through to the uncommitted layer (ADR-030). + #[error("branch '{branch}' has no Graphite stack and no upstream to infer one from")] + #[diagnostic( + code(workon::review::stack_no_upstream), + help( + "set an upstream (git branch --set-upstream-to=/{branch}), \ + or run 'git workon review uncommitted'" + ) + )] + NoUpstream { branch: String }, + + /// Assembling the requested stack failed for a reason other than a missing upstream + /// (broken Graphite metadata, an unresolvable branch, a bad recorded parent revision). + #[error("failed to assemble the stack for '{branch}'")] + #[diagnostic(code(workon::review::stack_resolution_failed))] + StackResolutionFailed { + branch: String, + #[source] + source: workon::WorkonError, + }, + + /// A `` argument doesn't resolve to anything reviewable yet — real ref/range/PR + /// resolution lands in a later changeset (ADR-030); this is CS2's honest interim failure. + #[error("cannot resolve '{text}' as a review source")] + #[diagnostic( + code(workon::review::unresolvable_source), + help("try 'stack' or 'uncommitted' — refs, ranges, and PRs are not yet supported") + )] + UnresolvableSource { text: String }, +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index fb07145..b45d452 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -28,6 +28,7 @@ pub mod outline; pub mod queue; pub mod refresh; pub mod render; +pub mod source; pub mod stage_op; pub mod synthesis; pub mod terminal_query; diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index a7bb074..f36969e 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -8,13 +8,18 @@ use workon_review::acquire::{diff_changeset, resolve_changesets}; use workon_review::app::{App, ChangesetView, Severity}; use workon_review::config::{self, ReviewConfig}; use workon_review::keymap::Keymap; +use workon_review::source::{resolve_source, Source}; use workon_review::terminal_query; use workon_review::theme::Palette; /// A TUI for reviewing changesets #[derive(Debug, Parser)] #[clap(about, author, bin_name = env!("CARGO_PKG_NAME"), version)] -struct Cli {} +struct Cli { + /// What to review: stack, uncommitted, or (later CSes) a ref/range/PR + #[arg(value_name = "SOURCE")] + source: Option, +} fn main() -> Result<()> { // Respond to the `COMPLETE=` dynamic-completion protocol before anything else — mirrors @@ -22,7 +27,7 @@ fn main() -> Result<()> { // what lets git-workon delegate `git workon review ` completion here (M6 CS3). CompleteEnv::with_factory(Cli::command).complete(); - Cli::parse(); + let cli = Cli::parse(); let repo = Repository::discover(".").into_diagnostic()?; let branch = repo @@ -32,10 +37,17 @@ fn main() -> Result<()> { .into_diagnostic()? .to_string(); - // `resolve_changesets` is the M5 entry point (locked decision #7, auto-detect): the full - // Graphite stack when one is active, or a single synthetic uncommitted changeset otherwise - // — the latter keeps a non-Graphite repo byte-identical to M2–M4's `diff_uncommitted` path. - let changesets = resolve_changesets(&repo, &branch).into_diagnostic()?; + // No `[SOURCE]` argument: the M5 auto-detect entry point (locked decision #7), unchanged — + // the full Graphite stack when one is active, or a single synthetic uncommitted changeset + // otherwise (keeps a non-Graphite repo byte-identical to M2–M4's `diff_uncommitted` path). + // A `[SOURCE]` argument routes through the ADR-030 classifier/resolver instead (M7 CS2). + let changesets = match cli.source { + None => resolve_changesets(&repo, &branch).into_diagnostic()?, + Some(text) => { + let source = Source::classify(&text); + resolve_source(&repo, &branch, source).into_diagnostic()? + } + }; let mut views = Vec::with_capacity(changesets.len()); for cs in changesets { diff --git a/git-workon-review/src/source.rs b/git-workon-review/src/source.rs new file mode 100644 index 0000000..0db974c --- /dev/null +++ b/git-workon-review/src/source.rs @@ -0,0 +1,150 @@ +//! Classifying and resolving a `git workon review []` positional argument (ADR-030). +//! +//! [`Source::classify`] is pure — no repository access, deterministic regardless of repo +//! state — so a branch literally named `stack` only matches the keyword when spelled bare; +//! `refs/heads/stack` or `heads/stack` classify as [`Source::Ref`]. Resolution +//! ([`resolve_source`]) is where repo state comes in. +//! +//! CS2 ships only the variants it can resolve: [`Source::Stack`], [`Source::Uncommitted`], and +//! [`Source::Ref`] (whose resolution is a named, honest failure until CS3 wires ref/range +//! dispatch). `Range`/`Pr` variants land with their own changesets — no dead arms here yet. + +use git2::Repository; +use workon::{assemble_changesets, ChangesetError, StackModel, UncommittedLayer, WorkonError}; + +use crate::acquire::uncommitted_changeset; +use crate::error::SourceError; + +/// What a `git workon review ` argument was classified as (ADR-030 precedence). +/// +/// `classify` only ever runs on `Some(text)` — the no-argument case stays the existing +/// auto-detect path in `main.rs` and never constructs a `Source` at all. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Source { + /// The exact bare word `stack`. + Stack, + /// The exact bare word `uncommitted`. + Uncommitted, + /// Everything else — a candidate ref, resolved (CS3) or rejected (CS2) by shape. + Ref(String), +} + +impl Source { + /// Classify `text` per ADR-030's precedence. In CS2 that precedence is just "exact bare + /// keyword, else `Ref`" — the PR/range arms are added ahead of the keyword check as later + /// changesets extend this function, not this call site. + pub fn classify(text: &str) -> Source { + match text { + "stack" => Source::Stack, + "uncommitted" => Source::Uncommitted, + other => Source::Ref(other.to_string()), + } + } +} + +/// Resolve a classified [`Source`] to the changesets it names, for the worktree whose `HEAD` +/// is `head_branch`. +/// +/// Unlike [`crate::acquire::resolve_changesets`] (the no-argument auto-detect path), every +/// arm here is an explicit ask: `Stack` never silently falls back to the uncommitted layer on +/// a missing upstream, and an unresolvable `Ref` is a named pre-TUI error, never a fallback to +/// auto-detect (ADR-030's "no surprise reviews" rule). +pub fn resolve_source( + repo: &Repository, + head_branch: &str, + source: Source, +) -> Result, SourceError> { + match source { + Source::Stack => resolve_stack(repo, head_branch), + Source::Uncommitted => Ok(vec![uncommitted_changeset(head_branch)]), + Source::Ref(text) => Err(SourceError::UnresolvableSource { text }), + } +} + +/// `stack` keyword resolution: Graphite metadata when active, otherwise the git-inference arm +/// (`StackModel::Git`, first wired into the binary here) — never a silent downgrade to +/// `StackModel::None`'s empty result, since the keyword is an explicit ask for the real stack. +/// The uncommitted layer rides along (`UncommittedLayer::Include`): `stack` always means +/// "focused on real `HEAD`." +fn resolve_stack( + repo: &Repository, + head_branch: &str, +) -> Result, SourceError> { + let model = if StackModel::detect(repo) == StackModel::Graphite { + StackModel::Graphite + } else { + StackModel::Git + }; + + assemble_changesets(repo, head_branch, model, UncommittedLayer::Include).map_err( + |err| match err { + WorkonError::Changeset(ChangesetError::NoUpstream { branch }) => { + SourceError::NoUpstream { branch } + } + other => SourceError::StackResolutionFailed { + branch: head_branch.to_string(), + source: other, + }, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_bare_stack_is_stack_keyword() { + assert_eq!(Source::classify("stack"), Source::Stack); + } + + #[test] + fn classify_bare_uncommitted_is_uncommitted_keyword() { + assert_eq!(Source::classify("uncommitted"), Source::Uncommitted); + } + + #[test] + fn classify_qualified_stack_ref_is_ref_not_keyword() { + assert_eq!( + Source::classify("refs/heads/stack"), + Source::Ref("refs/heads/stack".to_string()) + ); + assert_eq!( + Source::classify("heads/stack"), + Source::Ref("heads/stack".to_string()) + ); + } + + #[test] + fn classify_qualified_uncommitted_ref_is_ref_not_keyword() { + assert_eq!( + Source::classify("refs/heads/uncommitted"), + Source::Ref("refs/heads/uncommitted".to_string()) + ); + } + + #[test] + fn classify_is_case_sensitive() { + assert_eq!(Source::classify("Stack"), Source::Ref("Stack".to_string())); + assert_eq!( + Source::classify("Uncommitted"), + Source::Ref("Uncommitted".to_string()) + ); + assert_eq!(Source::classify("STACK"), Source::Ref("STACK".to_string())); + } + + #[test] + fn classify_arbitrary_text_is_ref() { + assert_eq!(Source::classify("main"), Source::Ref("main".to_string())); + assert_eq!(Source::classify("a..b"), Source::Ref("a..b".to_string())); + assert_eq!( + Source::classify("deadbeef"), + Source::Ref("deadbeef".to_string()) + ); + } + + #[test] + fn classify_empty_string_is_ref() { + assert_eq!(Source::classify(""), Source::Ref(String::new())); + } +} diff --git a/git-workon-review/tests/diff_model.rs b/git-workon-review/tests/diff_model.rs index c12df97..4da7a5f 100644 --- a/git-workon-review/tests/diff_model.rs +++ b/git-workon-review/tests/diff_model.rs @@ -7,7 +7,7 @@ use git2::{BranchType, Oid, Repository}; use git_workon_fixture::prelude::*; -use workon::{assemble_changesets, Changeset, ChangesetSpan, StackModel}; +use workon::{assemble_changesets, Changeset, ChangesetSpan, StackModel, UncommittedLayer}; use workon_review::acquire::{diff_changeset, diff_committed, diff_uncommitted, ChangesetDiff}; use workon_review::error::DiffError; use workon_review::model::{FileStatus, LineKind}; @@ -450,7 +450,8 @@ fn diff_changeset_over_real_graphite_stack() -> Result<(), Box {$( + mod $name { + use super::*; + #[test] fn refs() { super::$name(MetadataFormat::Refs).unwrap() } + #[test] fn sqlite() { super::$name(MetadataFormat::Sqlite).unwrap() } + } + )+}; +} + +both_formats!( + stack_keyword_in_graphite_repo_returns_full_stack, + uncommitted_keyword_in_graphite_repo_returns_single_uncommitted_changeset, +); + +fn stack_keyword_in_graphite_repo_returns_full_stack( + format: MetadataFormat, +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .metadata_format(format) + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .branch_metadata("b", "a") + .branch_metadata("c", "b") + .build()?; + let repo = fixture.repo()?; + + let changesets = resolve_source(repo, "b", Source::classify("stack"))?; + let names: Vec<&str> = changesets.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, vec!["a", "b", "c"]); + + let current: Vec<&str> = changesets + .iter() + .filter(|c| c.current) + .map(|c| c.name.as_str()) + .collect(); + assert_eq!(current, vec!["b"], "exactly the focused branch is current"); + Ok(()) +} + +fn uncommitted_keyword_in_graphite_repo_returns_single_uncommitted_changeset( + format: MetadataFormat, +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .metadata_format(format) + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .branch_metadata("b", "a") + .build()?; + let repo = fixture.repo()?; + + let changesets = resolve_source(repo, "b", Source::classify("uncommitted"))?; + assert_eq!(changesets.len(), 1, "always exactly one changeset"); + assert_eq!(changesets[0].span, ChangesetSpan::Uncommitted); + assert!(changesets[0].current); + assert_eq!( + changesets[0].name, "b", + "the uncommitted changeset is named after the focused branch, not the stack" + ); + Ok(()) +} + +#[test] +fn stack_keyword_in_plain_git_repo_with_upstream_returns_per_commit_changesets( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .remote("origin", "https://example.com/origin.git") + .upstream("main", "origin/main") + .build()?; + fixture.commit("main").file("a.txt", "1").create("first")?; + fixture.commit("main").file("b.txt", "2").create("second")?; + let repo = fixture.repo()?; + + let changesets = resolve_source(repo, "main", Source::classify("stack"))?; + assert_eq!(changesets.len(), 2, "one changeset per commit"); + assert_eq!(changesets[0].title.as_deref(), Some("first")); + assert_eq!(changesets[1].title.as_deref(), Some("second")); + assert!(changesets[1].current); + Ok(()) +} + +#[test] +fn stack_keyword_with_no_upstream_errors() -> Result<(), Box> { + let fixture = FixtureBuilder::new().build()?; + let repo = fixture.repo()?; + + let err = resolve_source(repo, "main", Source::classify("stack")).unwrap_err(); + match err { + SourceError::NoUpstream { branch } => assert_eq!(branch, "main"), + other => panic!("expected NoUpstream, got {other:?}"), + } + Ok(()) +} + +/// The classifier/resolver seam CS2 introduces resolves `Ref` to a named, hinted pre-TUI +/// failure (real ref resolution is CS3) — end-to-end through the binary, so this doubles as +/// the CS2 manual smoke check ("a source shape renders or errors honestly"). Color is pinned +/// off: `FORCE_COLOR=3` is set in this dev environment and would otherwise leak ANSI codes +/// into the assertion. +#[test] +fn unresolvable_ref_source_prints_named_error_and_exits_nonzero() { + let fixture = FixtureBuilder::new().build().unwrap(); + let repo = fixture.repo().unwrap(); + let workdir = repo.workdir().unwrap(); + + let mut cmd = cargo_bin_cmd!("git-workon-review"); + cmd.current_dir(workdir) + .env("NO_COLOR", "1") + .arg("no-such-thing") + .assert() + .failure() + .stderr(predicate::str::contains( + "cannot resolve 'no-such-thing' as a review source", + )); +} From 6df98419a3337878ac9de03e4782ca7aee604279 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 09:25:40 -0400 Subject: [PATCH 062/203] fix(review): show nothing-to-review for empty stack resolution --- git-workon-review/src/main.rs | 8 +++++++- git-workon-review/tests/source.rs | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index f36969e..eac1dd3 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -55,7 +55,13 @@ fn main() -> Result<()> { views.push(ChangesetView::from_changeset_diff(cs, diff)); } - if views.len() == 1 && views[0].file_count() == 0 { + // A resolved source can legitimately name zero changesets — `stack` on a branch that's + // caught up with its upstream and has a clean tree hits `assemble_git`'s empty-vec arm + // (see `git_inference_caught_up_and_clean_returns_empty` in git-workon-lib), same as the + // single-uncommitted-changeset case with nothing in it. Both are "nothing to review" + + // exit 0 (ADR-030), never a `views` list handed to `App::from_changesets`, which panics on + // empty input. + if views.is_empty() || (views.len() == 1 && views[0].file_count() == 0) { eprintln!("nothing to review"); return Ok(()); } diff --git a/git-workon-review/tests/source.rs b/git-workon-review/tests/source.rs index 6727946..6a375bf 100644 --- a/git-workon-review/tests/source.rs +++ b/git-workon-review/tests/source.rs @@ -105,6 +105,29 @@ fn stack_keyword_with_no_upstream_errors() -> Result<(), Box> { Ok(()) } +/// `stack` on a branch that's caught up with its upstream and has a clean tree resolves to +/// zero changesets (`assemble_git`'s empty-vec arm, `git_inference_caught_up_and_clean_returns_empty` +/// in `git-workon-lib/tests/changeset.rs`) — end-to-end through the binary this must print +/// "nothing to review" and exit 0, exactly like the no-argument auto-detect path, not panic. +#[test] +fn stack_keyword_caught_up_and_clean_prints_nothing_to_review() { + let fixture = FixtureBuilder::new() + .remote("origin", "https://example.com/origin.git") + .upstream("main", "origin/main") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + let workdir = repo.workdir().unwrap(); + + let mut cmd = cargo_bin_cmd!("git-workon-review"); + cmd.current_dir(workdir) + .env("NO_COLOR", "1") + .arg("stack") + .assert() + .success() + .stderr(predicate::str::contains("nothing to review")); +} + /// The classifier/resolver seam CS2 introduces resolves `Ref` to a named, hinted pre-TUI /// failure (real ref resolution is CS3) — end-to-end through the binary, so this doubles as /// the CS2 manual smoke check ("a source shape renders or errors honestly"). Color is pinned From 395d351045311c4a51caaa2909dc3475c50d0779 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 09:34:54 -0400 Subject: [PATCH 063/203] fix(review): refresh re-resolves the launched review source --- git-workon-review/src/app.rs | 90 ++++++++++++++++++++++++++++++++++- git-workon-review/src/main.rs | 14 ++++-- 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 3783858..194e399 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -25,6 +25,7 @@ use crate::ops; use crate::outline::{self, OutlineChangeset, OutlineFile, OutlineItem, OutlineMode}; use crate::queue::{OpOutcome, StagingOp, StagingQueue}; use crate::refresh::{IndexSignature, RefreshCoordinator}; +use crate::source::{resolve_source, Source}; use crate::stage_op::{FileStagingOp, LineSelectionOp}; use crate::synthesis::LineSelection; use crate::wordiff::{word_diff_spans, Span}; @@ -737,6 +738,14 @@ pub struct App { /// every key as a modal (mirroring [`Self::pending_confirm`]'s capture) — see its doc comment /// for the precedence between the two modals. pub help_visible: bool, + /// The `git workon review []` argument the session was launched with, set via + /// [`Self::set_review_source`] (M7 CS2 fix). `None` means the session was launched via + /// no-argument auto-detect (`crate::acquire::resolve_changesets`); `Some(source)` means an + /// explicit ask (`stack`, `uncommitted`, and later CS3/CS4's ref/range/PR variants) that + /// [`Self::refresh`] must re-resolve on every refresh, NEVER downgrade to auto-detect — a + /// setter (rather than a constructor parameter) so `App::from_changesets`'s signature, and + /// every existing test building through it, stays untouched. + review_source: Option, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -871,6 +880,7 @@ impl App { refresh_coordinator, outline, help_visible: false, + review_source: None, }; // 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 @@ -880,6 +890,15 @@ impl App { app } + /// Record the `[SOURCE]` argument the review session was launched with, so + /// [`Self::refresh`] re-resolves that same ask instead of silently falling back to + /// no-argument auto-detect (M7 CS2 fix). `main.rs` calls this right after + /// [`Self::from_changesets`] whenever a `[SOURCE]` argument was given; a no-argument launch + /// never calls it, leaving [`Self::review_source`] at its `None` default. + pub fn set_review_source(&mut self, source: Source) { + self.review_source = Some(source); + } + /// 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 @@ -996,6 +1015,14 @@ impl App { /// /// On any assembly/diff error, leaves all existing state untouched and sets an error /// [`Notice`] instead (via [`Self::notify`]) — a failed refresh must never blank the review. + /// + /// Dispatches on [`Self::review_source`] (M7 CS2 fix): a no-argument launch (`None`) re-runs + /// today's auto-detect ([`crate::acquire::resolve_changesets`]); an explicit-source launch + /// (`Some`) re-runs [`crate::source::resolve_source`] against THAT source, never auto-detect + /// — every CS2 source variant (`Stack`, `Uncommitted`) is offline, so re-resolving on every + /// refresh (manual `r` and the tick-driven index watcher alike) is cheap and safe. Without + /// this, both refresh triggers would silently swap an explicit review (e.g. `uncommitted`) + /// for the current `HEAD`'s auto-detected state. pub fn refresh(&mut self) { let Some(head_branch) = self .repo @@ -1007,7 +1034,13 @@ impl App { return; }; - let changesets = match crate::acquire::resolve_changesets(&self.repo, &head_branch) { + let changesets = match &self.review_source { + None => crate::acquire::resolve_changesets(&self.repo, &head_branch) + .map_err(|err| err.to_string()), + Some(source) => resolve_source(&self.repo, &head_branch, source.clone()) + .map_err(|err| err.to_string()), + }; + let changesets = match changesets { Ok(cs) => cs, Err(err) => { self.notify(format!("refresh failed: {err}"), Severity::Error); @@ -3591,6 +3624,61 @@ mod tests { assert_eq!(app.zoom, Zoom::Combined, "refresh must not reset zoom"); } + /// M7 CS2 fix: a session launched with an explicit `[SOURCE]` argument must have `refresh` + /// re-resolve THAT source, never silently downgrade to no-argument auto-detect. A Graphite + /// stack is active (`assemble_changesets` would return the whole `a`/`b` stack for + /// auto-detect), but the session was launched with `uncommitted` — so both the manual `r` + /// key and the tick-driven index watcher must keep showing only the single uncommitted + /// changeset, not swap in the full stack. + #[test] + fn refresh_re_resolves_the_launched_source_instead_of_auto_detecting() { + use crate::source::Source; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .branch_metadata("b", "a") + .untracked_file("scratch.txt", "hi\n") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + // `App::refresh` re-derives the branch from the repo's ACTUAL `HEAD`, not from a name + // handed to `resolve_source` — so the fixture's checkout must really be on "b" for + // auto-detect (were the fix absent) to see the `a`/`b` stack, not `main`. + repo.set_head("refs/heads/b").unwrap(); + repo.checkout_head(None).unwrap(); + + let source = Source::Uncommitted; + let changesets = + crate::source::resolve_source(repo, "b", source.clone()).expect("resolve_source"); + assert_eq!( + changesets.len(), + 1, + "uncommitted keyword always resolves to exactly one changeset" + ); + let mut views = Vec::with_capacity(changesets.len()); + for cs in changesets { + let diff = crate::acquire::diff_changeset(repo, &cs).unwrap(); + views.push(ChangesetView::from_changeset_diff(cs, diff)); + } + + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, views); + app.set_review_source(source); + app.open_current(); + + app.refresh(); + + assert_eq!( + app.changeset_count(), + 1, + "refresh must keep reviewing only the uncommitted changeset, not the full \ + Graphite stack auto-detect would find" + ); + assert_eq!(app.cur().cs.span, ChangesetSpan::Uncommitted); + } + // ---- M4 index watcher (`on_tick`) ------------------------------------------------------- /// Stage `path` in the fixture's index, exactly as an external `git add` would — the write diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index eac1dd3..101de97 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -41,12 +41,13 @@ fn main() -> Result<()> { // the full Graphite stack when one is active, or a single synthetic uncommitted changeset // otherwise (keeps a non-Graphite repo byte-identical to M2–M4's `diff_uncommitted` path). // A `[SOURCE]` argument routes through the ADR-030 classifier/resolver instead (M7 CS2). - let changesets = match cli.source { + // `source` is kept (not just the resolved changesets) so it can be handed to `App` below — + // `App::refresh` re-runs THIS same ask on every refresh rather than downgrading to + // auto-detect (M7 CS2 fix). + let source = cli.source.as_deref().map(Source::classify); + let changesets = match &source { None => resolve_changesets(&repo, &branch).into_diagnostic()?, - Some(text) => { - let source = Source::classify(&text); - resolve_source(&repo, &branch, source).into_diagnostic()? - } + Some(source) => resolve_source(&repo, &branch, source.clone()).into_diagnostic()?, }; let mut views = Vec::with_capacity(changesets.len()); @@ -97,6 +98,9 @@ fn main() -> Result<()> { // acquisition is done borrowing it. `App::from_changesets` opens on whichever changeset the // lib marked `current` (locked decision #6). let mut app = App::from_changesets(repo, views); + if let Some(source) = source { + app.set_review_source(source); + } // Apply CS7's view-config settings BEFORE `open_current`: `App::apply_view_config`'s setters // only set the raw layout/zoom/mode/width fields, and `open_current` is what derives From f5b0efa40ac3106b7d594767e59a0c4dacadad25 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 02:03:59 -0400 Subject: [PATCH 064/203] feat(review): resolve ref and range review sources --- git-workon-lib/src/changeset.rs | 5 + git-workon-review/src/acquire.rs | 18 ++ git-workon-review/src/app.rs | 16 +- git-workon-review/src/error.rs | 19 +- git-workon-review/src/main.rs | 12 +- git-workon-review/src/source.rs | 347 ++++++++++++++++++++++++++++-- git-workon-review/tests/source.rs | 326 +++++++++++++++++++++++++++- 7 files changed, 717 insertions(+), 26 deletions(-) diff --git a/git-workon-lib/src/changeset.rs b/git-workon-lib/src/changeset.rs index bb69fe0..0f5febe 100644 --- a/git-workon-lib/src/changeset.rs +++ b/git-workon-lib/src/changeset.rs @@ -38,6 +38,11 @@ use crate::stack::{graphite, StackModel}; pub enum ChangesetSpan { /// A committed range `base..head` — resolved OIDs only; the lib never diffs them itself. Committed { base: Oid, head: Oid }, + /// A committed range whose base is the empty tree: a root commit (no parent) reviewed on + /// its own, so every file in `head` renders as added. Only the review crate's `` + /// bare-commit-ish dispatch (ADR-030) constructs this — `assemble_graphite`/`assemble_git` + /// never do, since a stack node's base is always a real (or merge-base-derived) commit. + CommittedRoot { head: Oid }, /// Uncommitted working-tree + index changes relative to the current branch's head. Uncommitted, } diff --git a/git-workon-review/src/acquire.rs b/git-workon-review/src/acquire.rs index bc29d0a..e76755b 100644 --- a/git-workon-review/src/acquire.rs +++ b/git-workon-review/src/acquire.rs @@ -91,6 +91,21 @@ pub fn diff_committed(repo: &Repository, base: Oid, head: Oid) -> Result Result { + let head_tree = repo.find_commit(head)?.tree()?; + + let mut opts = DiffOptions::new(); + opts.context_lines(3); + let mut diff = repo.diff_tree_to_tree(None, Some(&head_tree), Some(&mut opts))?; + diff.find_similar(None)?; + + DiffModel::from_git2(&diff) +} + /// The diff for one [`Changeset`], shaped by its [`ChangesetSpan`]. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ChangesetDiff { @@ -109,6 +124,9 @@ pub fn diff_changeset(repo: &Repository, cs: &Changeset) -> Result diff_committed(repo, base, head) .map(ChangesetDiff::Committed) .map_err(|err| changeset_diff_failed(&cs.name, err)), + ChangesetSpan::CommittedRoot { head } => diff_committed_root(repo, head) + .map(ChangesetDiff::Committed) + .map_err(|err| changeset_diff_failed(&cs.name, err)), ChangesetSpan::Uncommitted => diff_uncommitted(repo) .map(ChangesetDiff::Uncommitted) .map_err(|err| changeset_diff_failed(&cs.name, err)), diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 194e399..eceabf7 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -316,6 +316,14 @@ impl FileView { fn old_side_tree_for(repo: &Repository, span: ChangesetSpan) -> Option> { match span { ChangesetSpan::Committed { base, .. } => repo.find_commit(base).and_then(|c| c.tree()).ok(), + // Root commit reviewed on its own: the old side is the empty tree. `treebuilder(None)` + // builds (and `write` persists, idempotently — git's well-known empty-tree object) an + // empty tree without needing a real parent commit to peel. + ChangesetSpan::CommittedRoot { .. } => repo + .treebuilder(None) + .and_then(|b| b.write()) + .and_then(|oid| repo.find_tree(oid)) + .ok(), ChangesetSpan::Uncommitted => repo.head().and_then(|h| h.peel_to_tree()).ok(), } } @@ -332,6 +340,7 @@ fn old_side_tree_for(repo: &Repository, span: ChangesetSpan) -> Option Option> { match span { ChangesetSpan::Committed { head, .. } => repo.find_commit(head).and_then(|c| c.tree()).ok(), + ChangesetSpan::CommittedRoot { head } => repo.find_commit(head).and_then(|c| c.tree()).ok(), ChangesetSpan::Uncommitted => None, } } @@ -986,7 +995,10 @@ impl App { /// committed-mode guard: the mode-aware staging refusal, skipping combined attribution (no /// staged/unstaged sets exist to color by), and locking zoom to combined. pub fn is_committed(&self) -> bool { - matches!(self.cur().cs.span, ChangesetSpan::Committed { .. }) + matches!( + self.cur().cs.span, + ChangesetSpan::Committed { .. } | ChangesetSpan::CommittedRoot { .. } + ) } /// Re-run [`crate::acquire::resolve_changesets`] against the CURRENT `HEAD` branch and @@ -2512,6 +2524,8 @@ fn base_label_for(cs: &Changeset) -> String { let full = base.to_string(); full.chars().take(7).collect() } + // No real base commit to abbreviate — the base is the empty tree. + ChangesetSpan::CommittedRoot { .. } => "(empty)".to_string(), ChangesetSpan::Uncommitted => "HEAD".to_string(), } } diff --git a/git-workon-review/src/error.rs b/git-workon-review/src/error.rs index 300d90c..ccc96c8 100644 --- a/git-workon-review/src/error.rs +++ b/git-workon-review/src/error.rs @@ -151,12 +151,25 @@ pub enum SourceError { source: workon::WorkonError, }, - /// A `` argument doesn't resolve to anything reviewable yet — real ref/range/PR - /// resolution lands in a later changeset (ADR-030); this is CS2's honest interim failure. + /// A `` argument (or one side of a `Range`) doesn't rev-parse to anything reviewable — + /// a typo, a deleted branch, a garbage commit-ish. PR resolution lands in CS4; until then a + /// PR-shaped argument falls through to this same honest failure. #[error("cannot resolve '{text}' as a review source")] #[diagnostic( code(workon::review::unresolvable_source), - help("try 'stack' or 'uncommitted' — refs, ranges, and PRs are not yet supported") + help("try 'stack', 'uncommitted', a branch/tag/commit, or a..b / a...b range — PRs are not yet supported") )] UnresolvableSource { text: String }, + + /// An untracked (or remote-tracking) `` branch has neither an upstream nor a resolvable + /// trunk to compute "what this branch adds" from. + #[error("branch '{branch}' has no upstream and no trunk to compute a base from")] + #[diagnostic( + code(workon::review::no_base_for_branch), + help( + "set an upstream (git branch --set-upstream-to=/{branch}), \ + or ensure a trunk branch (main/master) exists" + ) + )] + NoBaseForBranch { branch: String }, } diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 101de97..3beb772 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -16,7 +16,8 @@ use workon_review::theme::Palette; #[derive(Debug, Parser)] #[clap(about, author, bin_name = env!("CARGO_PKG_NAME"), version)] struct Cli { - /// What to review: stack, uncommitted, or (later CSes) a ref/range/PR + /// What to review: stack, uncommitted, a ref (branch/tag/commit), a..b / a...b range, or + /// (CS4) a PR reference #[arg(value_name = "SOURCE")] source: Option, } @@ -40,7 +41,7 @@ fn main() -> Result<()> { // No `[SOURCE]` argument: the M5 auto-detect entry point (locked decision #7), unchanged — // the full Graphite stack when one is active, or a single synthetic uncommitted changeset // otherwise (keeps a non-Graphite repo byte-identical to M2–M4's `diff_uncommitted` path). - // A `[SOURCE]` argument routes through the ADR-030 classifier/resolver instead (M7 CS2). + // A `[SOURCE]` argument routes through the ADR-030 classifier/resolver instead (M7 CS2/CS3). // `source` is kept (not just the resolved changesets) so it can be handed to `App` below — // `App::refresh` re-runs THIS same ask on every refresh rather than downgrading to // auto-detect (M7 CS2 fix). @@ -63,7 +64,12 @@ fn main() -> Result<()> { // exit 0 (ADR-030), never a `views` list handed to `App::from_changesets`, which panics on // empty input. if views.is_empty() || (views.len() == 1 && views[0].file_count() == 0) { - eprintln!("nothing to review"); + // Name the source when one was given (CS3) — a bare `nothing to review` would leave a + // typo'd-but-empty range like `v1..v1` looking indistinguishable from the no-arg case. + match cli.source.as_deref() { + Some(text) => eprintln!("nothing to review in {text}"), + None => eprintln!("nothing to review"), + } return Ok(()); } diff --git a/git-workon-review/src/source.rs b/git-workon-review/src/source.rs index 0db974c..5c1b544 100644 --- a/git-workon-review/src/source.rs +++ b/git-workon-review/src/source.rs @@ -5,16 +5,28 @@ //! `refs/heads/stack` or `heads/stack` classify as [`Source::Ref`]. Resolution //! ([`resolve_source`]) is where repo state comes in. //! -//! CS2 ships only the variants it can resolve: [`Source::Stack`], [`Source::Uncommitted`], and -//! [`Source::Ref`] (whose resolution is a named, honest failure until CS3 wires ref/range -//! dispatch). `Range`/`Pr` variants land with their own changesets — no dead arms here yet. +//! CS3 wires real `` dispatch (shape-aware: Graphite-tracked branch, other branch, +//! bare commit-ish) and `Range` (`a..b` / `a...b`, git-diff semantics). `Pr` still lands with +//! its own changeset — no dead arm here yet (CS4). -use git2::Repository; -use workon::{assemble_changesets, ChangesetError, StackModel, UncommittedLayer, WorkonError}; +use git2::{BranchType, Oid, Repository}; +use workon::{ + assemble_changesets, get_default_branch, graphite_trunk, ChangesetError, ChangesetSpan, + StackModel, UncommittedLayer, WorkonError, +}; use crate::acquire::uncommitted_changeset; use crate::error::SourceError; +/// Which dot form a [`Source::Range`] was spelled with — git-diff semantics differ (ADR-030). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RangeDots { + /// `a..b` — base `a`, head `b` (endpoint trees, exactly a committed span). + Two, + /// `a...b` — base `merge-base(a, b)`, head `b` (the PR-style "what did b add"). + Three, +} + /// What a `git workon review ` argument was classified as (ADR-030 precedence). /// /// `classify` only ever runs on `Some(text)` — the no-argument case stays the existing @@ -25,20 +37,44 @@ pub enum Source { Stack, /// The exact bare word `uncommitted`. Uncommitted, - /// Everything else — a candidate ref, resolved (CS3) or rejected (CS2) by shape. + /// `..` or `...` — either side may be empty, defaulting to `HEAD` + /// at resolution time (classification stays pure/repo-state-free). + Range { + base_text: String, + head_text: String, + dots: RangeDots, + }, + /// Everything else — a candidate ref, resolved by shape (CS3). Ref(String), } impl Source { - /// Classify `text` per ADR-030's precedence. In CS2 that precedence is just "exact bare - /// keyword, else `Ref`" — the PR/range arms are added ahead of the keyword check as later - /// changesets extend this function, not this call site. + /// Classify `text` per ADR-030's precedence: exact bare keyword, else a range (three-dot + /// checked before two-dot, since `...` contains `..`), else `Ref`. Keywords are checked + /// first — they're exact-bare and contain no dots, so the order between "keyword" and + /// "range" never actually competes, but reading top-to-bottom matches the ADR's precedence + /// list. pub fn classify(text: &str) -> Source { match text { - "stack" => Source::Stack, - "uncommitted" => Source::Uncommitted, - other => Source::Ref(other.to_string()), + "stack" => return Source::Stack, + "uncommitted" => return Source::Uncommitted, + _ => {} + } + if let Some((base_text, head_text)) = text.split_once("...") { + return Source::Range { + base_text: base_text.to_string(), + head_text: head_text.to_string(), + dots: RangeDots::Three, + }; } + if let Some((base_text, head_text)) = text.split_once("..") { + return Source::Range { + base_text: base_text.to_string(), + head_text: head_text.to_string(), + dots: RangeDots::Two, + }; + } + Source::Ref(text.to_string()) } } @@ -57,7 +93,12 @@ pub fn resolve_source( match source { Source::Stack => resolve_stack(repo, head_branch), Source::Uncommitted => Ok(vec![uncommitted_changeset(head_branch)]), - Source::Ref(text) => Err(SourceError::UnresolvableSource { text }), + Source::Range { + base_text, + head_text, + dots, + } => resolve_range(repo, &base_text, &head_text, dots), + Source::Ref(text) => resolve_ref(repo, head_branch, text), } } @@ -89,6 +130,221 @@ fn resolve_stack( ) } +/// `` resolution — shape-aware dispatch (ADR-030), checked in order: +/// +/// 1. A Graphite-tracked LOCAL branch (`text` names a local branch, qualified spellings like +/// `refs/heads/`/`heads/` included, AND that branch has a Graphite metadata row) +/// → the whole stack focused there, exactly like `stack` but pinned to `text`'s branch +/// instead of real `HEAD`. The uncommitted layer rides along only when the resolved branch +/// IS `head_branch` — this is the first caller to pass [`UncommittedLayer::Omit`]. +/// 2. Any other branch (an untracked local branch, or a remote-tracking branch like +/// `origin/foo`) → one committed changeset, "what this branch adds": base = +/// `merge-base(upstream, branch)` when a local branch has an upstream, else +/// `merge-base(trunk, branch)`. +/// 3. A bare commit-ish (sha, tag, `HEAD~2`) that rev-parses to a commit but isn't a branch → +/// one changeset spanning just that commit (`parent..ref`, or [`ChangesetSpan::CommittedRoot`] +/// for a parentless root commit). +/// 4. Nothing rev-parses → [`SourceError::UnresolvableSource`]. +fn resolve_ref( + repo: &Repository, + head_branch: &str, + text: String, +) -> Result, SourceError> { + if let Some(branch_name) = resolve_local_branch_name(repo, &text) { + if workon::current_stack(repo, &branch_name, StackModel::Graphite) + .ok() + .flatten() + .is_some() + { + let layer = if branch_name == head_branch { + UncommittedLayer::Include + } else { + UncommittedLayer::Omit + }; + return assemble_changesets(repo, &branch_name, StackModel::Graphite, layer).map_err( + |err| match err { + WorkonError::Changeset(ChangesetError::NoUpstream { branch }) => { + SourceError::NoUpstream { branch } + } + other => SourceError::StackResolutionFailed { + branch: branch_name.clone(), + source: other, + }, + }, + ); + } + + // Untracked local branch: "what this branch adds" vs its upstream, else the trunk. + let branch = repo + .find_branch(&branch_name, BranchType::Local) + .map_err(|_| SourceError::UnresolvableSource { text: text.clone() })?; + let head_oid = branch + .get() + .target() + .ok_or_else(|| SourceError::UnresolvableSource { text: text.clone() })?; + let upstream_oid = branch.upstream().ok().and_then(|u| u.get().target()); + return one_changeset_from_branch(repo, &text, head_oid, upstream_oid); + } + + // Remote-tracking branch (e.g. "origin/foo"): no upstream concept of its own, base always + // comes from the trunk. + if let Ok(branch) = repo.find_branch(&text, BranchType::Remote) { + let head_oid = branch + .get() + .target() + .ok_or_else(|| SourceError::UnresolvableSource { text: text.clone() })?; + return one_changeset_from_branch(repo, &text, head_oid, None); + } + + // Bare commit-ish: sha, tag, `HEAD~2`, etc. — rev-parses to a commit but isn't a branch. + if let Some(head_oid) = revparse_to_commit(repo, &text) { + let commit = repo + .find_commit(head_oid) + .map_err(|_| SourceError::UnresolvableSource { text: text.clone() })?; + let span = match commit.parent_id(0) { + Ok(base) => ChangesetSpan::Committed { + base, + head: head_oid, + }, + // A root commit has no parent to diff against — the empty tree stands in. + Err(_) => ChangesetSpan::CommittedRoot { head: head_oid }, + }; + return Ok(vec![workon::Changeset { + name: text, + span, + title: None, + current: true, + needs_restack: false, + }]); + } + + Err(SourceError::UnresolvableSource { text }) +} + +/// `Range` resolution: rev-parse each endpoint (an empty side defaults to `HEAD`), then combine +/// per [`RangeDots`] — `a..b` spans the endpoints directly; `a...b` bases off their merge-base. +/// One committed changeset either way, named after the source text exactly as typed. Never a +/// candidate for the uncommitted layer (ADR-030: committed-only, like every source but `stack` +/// and a `` on real `HEAD`). +fn resolve_range( + repo: &Repository, + base_text: &str, + head_text: &str, + dots: RangeDots, +) -> Result, SourceError> { + let base_oid = resolve_endpoint(repo, base_text)?; + let head_oid = resolve_endpoint(repo, head_text)?; + + let base_oid = match dots { + RangeDots::Two => base_oid, + RangeDots::Three => { + repo.merge_base(base_oid, head_oid) + .map_err(|_| SourceError::UnresolvableSource { + text: format!("{base_text}...{head_text}"), + })? + } + }; + + let name = match dots { + RangeDots::Two => format!("{base_text}..{head_text}"), + RangeDots::Three => format!("{base_text}...{head_text}"), + }; + + Ok(vec![workon::Changeset { + name, + span: ChangesetSpan::Committed { + base: base_oid, + head: head_oid, + }, + title: None, + current: true, + needs_restack: false, + }]) +} + +/// Rev-parse one range endpoint, peeled to a commit; an empty `text` defaults to `HEAD` (ADR-030). +fn resolve_endpoint(repo: &Repository, text: &str) -> Result { + if text.is_empty() { + return repo + .head() + .and_then(|h| h.peel_to_commit()) + .map(|c| c.id()) + .map_err(|_| SourceError::UnresolvableSource { + text: "HEAD".to_string(), + }); + } + revparse_to_commit(repo, text).ok_or_else(|| SourceError::UnresolvableSource { + text: text.to_string(), + }) +} + +/// Rev-parse `text` and peel it to a commit, or `None` if it doesn't resolve to one. +fn revparse_to_commit(repo: &Repository, text: &str) -> Option { + repo.revparse_single(text) + .ok() + .and_then(|obj| obj.peel_to_commit().ok()) + .map(|c| c.id()) +} + +/// The resolved local branch name for `text`: an exact bare match, or a qualified spelling +/// (`refs/heads/`, `heads/`) that resolves to one — the same escapes ADR-030 gives +/// for the `stack`/`uncommitted` keywords, so `refs/heads/` still counts as +/// "focused on real `HEAD`" (compares equal to `head_branch` after unwrapping). +fn resolve_local_branch_name(repo: &Repository, text: &str) -> Option { + if repo.find_branch(text, BranchType::Local).is_ok() { + return Some(text.to_string()); + } + for prefix in ["refs/heads/", "heads/"] { + if let Some(name) = text.strip_prefix(prefix) { + if repo.find_branch(name, BranchType::Local).is_ok() { + return Some(name.to_string()); + } + } + } + None +} + +/// One committed changeset spanning "what `branch` (named `text`) adds": base = +/// `merge-base(upstream_oid, head_oid)` when `upstream_oid` is `Some` (a local branch with an +/// upstream), else `merge-base(trunk, head_oid)` where trunk is the Graphite trunk if known, +/// else the repo's default branch. Neither resolving is [`SourceError::NoBaseForBranch`]. +fn one_changeset_from_branch( + repo: &Repository, + text: &str, + head_oid: Oid, + upstream_oid: Option, +) -> Result, SourceError> { + let no_base = || SourceError::NoBaseForBranch { + branch: text.to_string(), + }; + + let base_target = match upstream_oid { + Some(oid) => oid, + None => trunk_commit_oid(repo).ok_or_else(no_base)?, + }; + let base_oid = repo + .merge_base(base_target, head_oid) + .map_err(|_| no_base())?; + + Ok(vec![workon::Changeset { + name: text.to_string(), + span: ChangesetSpan::Committed { + base: base_oid, + head: head_oid, + }, + title: None, + current: true, + needs_restack: false, + }]) +} + +/// The trunk branch's tip commit: the Graphite trunk if known, else the repo's default branch +/// (`init.defaultBranch`/`main`/`master`) — `None` if neither resolves to a real commit. +fn trunk_commit_oid(repo: &Repository) -> Option { + let name = graphite_trunk(repo).or_else(|| get_default_branch(repo).ok())?; + revparse_to_commit(repo, &name) +} + #[cfg(test)] mod tests { use super::*; @@ -136,7 +392,6 @@ mod tests { #[test] fn classify_arbitrary_text_is_ref() { assert_eq!(Source::classify("main"), Source::Ref("main".to_string())); - assert_eq!(Source::classify("a..b"), Source::Ref("a..b".to_string())); assert_eq!( Source::classify("deadbeef"), Source::Ref("deadbeef".to_string()) @@ -147,4 +402,68 @@ mod tests { fn classify_empty_string_is_ref() { assert_eq!(Source::classify(""), Source::Ref(String::new())); } + + #[test] + fn classify_two_dot_range() { + assert_eq!( + Source::classify("a..b"), + Source::Range { + base_text: "a".to_string(), + head_text: "b".to_string(), + dots: RangeDots::Two, + } + ); + } + + #[test] + fn classify_three_dot_range() { + assert_eq!( + Source::classify("a...b"), + Source::Range { + base_text: "a".to_string(), + head_text: "b".to_string(), + dots: RangeDots::Three, + } + ); + } + + #[test] + fn classify_range_empty_sides() { + assert_eq!( + Source::classify("..main"), + Source::Range { + base_text: String::new(), + head_text: "main".to_string(), + dots: RangeDots::Two, + } + ); + assert_eq!( + Source::classify("main.."), + Source::Range { + base_text: "main".to_string(), + head_text: String::new(), + dots: RangeDots::Two, + } + ); + assert_eq!( + Source::classify(".."), + Source::Range { + base_text: String::new(), + head_text: String::new(), + dots: RangeDots::Two, + } + ); + } + + #[test] + fn classify_dotted_text_mixed_with_keyword_text_is_range() { + assert_eq!( + Source::classify("stack..main"), + Source::Range { + base_text: "stack".to_string(), + head_text: "main".to_string(), + dots: RangeDots::Two, + } + ); + } } diff --git a/git-workon-review/tests/source.rs b/git-workon-review/tests/source.rs index 6a375bf..551b953 100644 --- a/git-workon-review/tests/source.rs +++ b/git-workon-review/tests/source.rs @@ -1,13 +1,13 @@ -//! Fixture tests for the M7 CS2 `Source` classifier + resolver (ADR-030): the `stack` and -//! `uncommitted` keywords, in both a Graphite-managed repo and a plain-git repo. `Ref` -//! resolution is a named interim failure until CS3 wires ref/range dispatch — see -//! `unresolvable_source_prints_error` in `tests/cli.rs`-style output assertions below (pinned -//! `NO_COLOR=1`, per the FORCE_COLOR trap this environment sets). +//! Fixture tests for the M7 `Source` classifier + resolver (ADR-030): the `stack`/`uncommitted` +//! keywords (CS2), and `` shape-aware dispatch + `Range` resolution (CS3). Output +//! assertions pin `NO_COLOR=1` per the FORCE_COLOR trap this dev environment sets. use assert_cmd::cargo_bin_cmd; +use git2::ObjectType; use git_workon_fixture::prelude::*; use std::error::Error; use workon::ChangesetSpan; +use workon_review::acquire::{diff_changeset, resolve_changesets, ChangesetDiff}; use workon_review::error::SourceError; use workon_review::source::{resolve_source, Source}; @@ -149,3 +149,319 @@ fn unresolvable_ref_source_prints_named_error_and_exits_nonzero() { "cannot resolve 'no-such-thing' as a review source", )); } + +// ── CS3: `` shape-aware dispatch + `Range` resolution ────────────────────────────────── + +both_formats!(ref_on_graphite_tracked_branch_that_is_head_matches_auto_detect,); + +/// A `` naming the Graphite-tracked branch that IS real `HEAD` must resolve identically to +/// auto-detect (ADR-030: the uncommitted layer rides along). A dirty tree (an untracked file) +/// makes the layer's presence in both outputs an actual assertion, not a vacuous one. +fn ref_on_graphite_tracked_branch_that_is_head_matches_auto_detect( + format: MetadataFormat, +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .metadata_format(format) + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .branch_metadata("b", "a") + .untracked_file("dirty.txt", "wip") + .build()?; + let repo = fixture.repo()?; + + let auto = resolve_changesets(repo, "b")?; + let via_ref = resolve_source(repo, "b", Source::classify("b"))?; + assert_eq!(auto, via_ref); + assert!( + auto.iter().any(|cs| cs.span == ChangesetSpan::Uncommitted), + "a dirty tree on real HEAD must carry the uncommitted layer" + ); + Ok(()) +} + +/// A `` naming a Graphite-tracked branch that is NOT real `HEAD` never gets the +/// uncommitted layer, even on a dirty tree — the dirty tree belongs to whatever branch is +/// actually checked out, not the reviewed one (ADR-030). +#[test] +fn ref_on_graphite_tracked_branch_that_is_not_head_omits_uncommitted_layer_on_dirty_tree( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .branch_metadata("b", "a") + .untracked_file("dirty.txt", "wip") + .build()?; + let repo = fixture.repo()?; + + // head_branch is deliberately NOT "b": the real HEAD is some other branch entirely. + let changesets = resolve_source(repo, "some-other-branch", Source::classify("b"))?; + assert!( + !changesets + .iter() + .any(|cs| cs.span == ChangesetSpan::Uncommitted), + "reviewing a non-HEAD branch must never surface the uncommitted layer" + ); + let current: Vec<&str> = changesets + .iter() + .filter(|c| c.current) + .map(|c| c.name.as_str()) + .collect(); + assert_eq!(current, vec!["b"]); + Ok(()) +} + +/// An untracked local branch with an upstream resolves to one committed changeset spanning +/// `merge-base(upstream, branch)..branch` — "what this branch adds". +#[test] +fn untracked_branch_with_upstream_bases_on_merge_base_with_upstream() -> Result<(), Box> +{ + let fixture = FixtureBuilder::new() + .remote("origin", "https://example.com/origin.git") + .upstream("main", "origin/main") + .build()?; + // `.upstream()` pins `origin/main` to the branch's tip AT BUILD TIME (the root commit) — + // both commits below land after that, so the upstream-anchored merge-base is the root. + let base_oid = fixture.head()?.peel_to_commit()?.id(); + fixture.commit("main").file("a.txt", "1").create("first")?; + let head_oid = fixture.commit("main").file("b.txt", "2").create("second")?; + let repo = fixture.repo()?; + + let changesets = resolve_source(repo, "main", Source::classify("main"))?; + assert_eq!(changesets.len(), 1); + match changesets[0].span { + ChangesetSpan::Committed { base, head } => { + assert_eq!(base, base_oid, "base is the upstream-anchored merge-base"); + assert_eq!(head, head_oid); + } + other => panic!("expected Committed, got {other:?}"), + } + assert_eq!(changesets[0].name, "main"); + Ok(()) +} + +/// An untracked local branch with NO upstream falls back to `merge-base(trunk, branch)`, where +/// trunk is the repo's default branch (no Graphite trunk configured here). +#[test] +fn untracked_branch_without_upstream_bases_on_merge_base_with_trunk() -> Result<(), Box> +{ + let fixture = FixtureBuilder::new() + .default_branch("main") + .worktree("feature") + .build()?; + fixture + .commit("main") + .file("a.txt", "1") + .create("on main")?; + let feature_head = fixture + .commit("feature") + .file("b.txt", "1") + .create("on feature")?; + let repo = fixture.repo()?; + + let changesets = resolve_source(repo, "feature", Source::classify("feature"))?; + assert_eq!(changesets.len(), 1); + match changesets[0].span { + ChangesetSpan::Committed { base, head } => { + let main_tip = repo + .find_branch("main", git2::BranchType::Local)? + .get() + .target() + .unwrap(); + let expected_base = repo.merge_base(main_tip, feature_head)?; + assert_eq!(base, expected_base, "base is the trunk-anchored merge-base"); + assert_eq!(head, feature_head); + } + other => panic!("expected Committed, got {other:?}"), + } + Ok(()) +} + +/// An untracked branch with neither an upstream nor a resolvable trunk is a named error, not a +/// silent fallback. +#[test] +fn untracked_branch_with_no_upstream_and_no_trunk_errors() -> Result<(), Box> { + let fixture = FixtureBuilder::new().default_branch("solo").build()?; + let repo = fixture.repo()?; + + let err = resolve_source(repo, "solo", Source::classify("solo")).unwrap_err(); + match err { + SourceError::NoBaseForBranch { branch } => assert_eq!(branch, "solo"), + other => panic!("expected NoBaseForBranch, got {other:?}"), + } + Ok(()) +} + +/// A bare commit sha resolves to one changeset spanning `parent..sha`. +#[test] +fn commit_sha_resolves_to_parent_and_sha() -> Result<(), Box> { + let fixture = FixtureBuilder::new().build()?; + let parent_oid = fixture.head()?.peel_to_commit()?.id(); + let head_oid = fixture.commit("main").file("a.txt", "1").create("first")?; + let repo = fixture.repo()?; + + let sha = head_oid.to_string(); + let changesets = resolve_source(repo, "main", Source::classify(&sha))?; + assert_eq!(changesets.len(), 1); + match changesets[0].span { + ChangesetSpan::Committed { base, head } => { + assert_eq!(base, parent_oid); + assert_eq!(head, head_oid); + } + other => panic!("expected Committed, got {other:?}"), + } + assert_eq!(changesets[0].name, sha); + assert!(changesets[0].current); + Ok(()) +} + +/// A tag resolves to the commit it points at, same as a bare sha. +#[test] +fn tag_resolves_to_tagged_commit() -> Result<(), Box> { + let fixture = FixtureBuilder::new().build()?; + let parent_oid = fixture.head()?.peel_to_commit()?.id(); + let head_oid = fixture.commit("main").file("a.txt", "1").create("first")?; + let repo = fixture.repo()?; + let tagged = repo.find_object(head_oid, Some(ObjectType::Commit))?; + repo.tag_lightweight("v1", &tagged, false)?; + + let changesets = resolve_source(repo, "main", Source::classify("v1"))?; + assert_eq!(changesets.len(), 1); + match changesets[0].span { + ChangesetSpan::Committed { base, head } => { + assert_eq!(base, parent_oid); + assert_eq!(head, head_oid); + } + other => panic!("expected Committed, got {other:?}"), + } + Ok(()) +} + +/// A root commit (no parent) reviewed on its own must still render — its base is the empty +/// tree, so every file in it shows as added. The commit is a genuine orphan (parents: &[]) so +/// it has no ancestry to fall back on, addressed only by its own sha. +#[test] +fn root_commit_renders_against_the_empty_tree() -> Result<(), Box> { + let fixture = FixtureBuilder::new().build()?; + let repo = fixture.repo()?; + + let sig = git2::Signature::now("Test User", "test@example.com")?; + let blob_oid = repo.blob(b"hello")?; + let mut builder = repo.treebuilder(None)?; + builder.insert("a.txt", blob_oid, 0o100_644)?; + let tree_oid = builder.write()?; + let tree = repo.find_tree(tree_oid)?; + let root_oid = repo.commit(None, &sig, &sig, "orphan root", &tree, &[])?; + + let sha = root_oid.to_string(); + let changesets = resolve_source(repo, "main", Source::classify(&sha))?; + assert_eq!(changesets.len(), 1); + assert_eq!( + changesets[0].span, + ChangesetSpan::CommittedRoot { head: root_oid } + ); + + match diff_changeset(repo, &changesets[0])? { + ChangesetDiff::Committed(model) => { + assert!(!model.files.is_empty(), "root commit must render its file") + } + other => panic!("expected a Committed diff, got {other:?}"), + } + Ok(()) +} + +/// `a..b` and `a...b` diverge after the branches actually diverge: two-dot bases on `a` itself, +/// three-dot bases on their merge-base. +#[test] +fn two_dot_and_three_dot_ranges_differ_after_divergence() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .default_branch("main") + .worktree("feature") + .build()?; + fixture + .commit("main") + .file("a.txt", "1") + .create("on main")?; + fixture + .commit("feature") + .file("b.txt", "1") + .create("on feature")?; + let repo = fixture.repo()?; + + let main_tip = repo + .find_branch("main", git2::BranchType::Local)? + .get() + .target() + .unwrap(); + let feature_tip = repo + .find_branch("feature", git2::BranchType::Local)? + .get() + .target() + .unwrap(); + let expected_merge_base = repo.merge_base(main_tip, feature_tip)?; + + let two_dot = resolve_source(repo, "main", Source::classify("main..feature"))?; + let three_dot = resolve_source(repo, "main", Source::classify("main...feature"))?; + + match (&two_dot[0].span, &three_dot[0].span) { + ( + ChangesetSpan::Committed { base: b2, head: h2 }, + ChangesetSpan::Committed { base: b3, head: h3 }, + ) => { + assert_eq!(*h2, feature_tip); + assert_eq!(*h3, feature_tip); + assert_eq!(*b2, main_tip, "two-dot bases directly on the left endpoint"); + assert_eq!( + *b3, expected_merge_base, + "three-dot bases on the merge-base" + ); + assert_ne!(b2, b3, "two-dot and three-dot bases diverge"); + } + other => panic!("expected two Committed spans, got {other:?}"), + } + Ok(()) +} + +/// An empty side of a range defaults to `HEAD` at resolution time. +#[test] +fn range_empty_side_defaults_to_head() -> Result<(), Box> { + let fixture = FixtureBuilder::new().branch("old").build()?; + fixture + .commit("main") + .file("a.txt", "1") + .create("advance main")?; + let repo = fixture.repo()?; + let head_oid = repo.head()?.peel_to_commit()?.id(); + + let explicit = resolve_source(repo, "main", Source::classify("old..main"))?; + let defaulted = resolve_source(repo, "main", Source::classify("old.."))?; + // Names differ (source text as typed); spans must be identical — the empty side resolved + // to the exact same commit as writing `main` out explicitly. + assert_eq!(explicit[0].span, defaulted[0].span); + match defaulted[0].span { + ChangesetSpan::Committed { head, .. } => assert_eq!(head, head_oid), + other => panic!("expected Committed, got {other:?}"), + } + Ok(()) +} + +/// `review ..` is a valid-but-empty range: exit 0, "nothing to review" naming +/// the source text (ADR-030's empty-but-valid UX, extended in CS3 to name the source). +#[test] +fn empty_range_between_same_tag_prints_named_nothing_to_review_and_exits_zero() { + let fixture = FixtureBuilder::new().build().unwrap(); + let repo = fixture.repo().unwrap(); + let head_oid = repo.head().unwrap().peel_to_commit().unwrap().id(); + let tagged = repo + .find_object(head_oid, Some(ObjectType::Commit)) + .unwrap(); + repo.tag_lightweight("v1", &tagged, false).unwrap(); + let workdir = repo.workdir().unwrap(); + + let mut cmd = cargo_bin_cmd!("git-workon-review"); + cmd.current_dir(workdir) + .env("NO_COLOR", "1") + .arg("v1..v1") + .assert() + .success() + .stderr(predicate::str::contains("nothing to review in v1..v1")); +} From 2ff87460eee04259fa1dd3b25b82f53acfee0060 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 09:36:25 -0400 Subject: [PATCH 065/203] refactor(review): dedupe source error mapping and committed diffs --- git-workon-review/src/acquire.rs | 21 +++++++++++------ git-workon-review/src/app.rs | 5 ++-- git-workon-review/src/source.rs | 39 +++++++++++++++----------------- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/git-workon-review/src/acquire.rs b/git-workon-review/src/acquire.rs index e76755b..1e12042 100644 --- a/git-workon-review/src/acquire.rs +++ b/git-workon-review/src/acquire.rs @@ -83,12 +83,7 @@ pub fn diff_committed(repo: &Repository, base: Oid, head: Oid) -> Result Result Result { let head_tree = repo.find_commit(head)?.tree()?; + diff_trees(repo, None, &head_tree) +} + +/// Shared tail of [`diff_committed`] and [`diff_committed_root`]: diff `base` (the empty tree +/// when `None`) against `head`, with rename/copy detection via [`git2::Diff::find_similar`] so +/// renamed files come back as [`crate::model::FileStatus::Renamed`] instead of a delete+add +/// pair. +fn diff_trees( + repo: &Repository, + base: Option<&git2::Tree>, + head: &git2::Tree, +) -> Result { let mut opts = DiffOptions::new(); opts.context_lines(3); - let mut diff = repo.diff_tree_to_tree(None, Some(&head_tree), Some(&mut opts))?; + let mut diff = repo.diff_tree_to_tree(base, Some(head), Some(&mut opts))?; diff.find_similar(None)?; DiffModel::from_git2(&diff) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index eceabf7..2363d6b 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -339,8 +339,9 @@ fn old_side_tree_for(repo: &Repository, span: ChangesetSpan) -> Option Option> { match span { - ChangesetSpan::Committed { head, .. } => repo.find_commit(head).and_then(|c| c.tree()).ok(), - ChangesetSpan::CommittedRoot { head } => repo.find_commit(head).and_then(|c| c.tree()).ok(), + ChangesetSpan::Committed { head, .. } | ChangesetSpan::CommittedRoot { head } => { + repo.find_commit(head).and_then(|c| c.tree()).ok() + } ChangesetSpan::Uncommitted => None, } } diff --git a/git-workon-review/src/source.rs b/git-workon-review/src/source.rs index 5c1b544..60de389 100644 --- a/git-workon-review/src/source.rs +++ b/git-workon-review/src/source.rs @@ -117,17 +117,23 @@ fn resolve_stack( StackModel::Git }; - assemble_changesets(repo, head_branch, model, UncommittedLayer::Include).map_err( - |err| match err { - WorkonError::Changeset(ChangesetError::NoUpstream { branch }) => { - SourceError::NoUpstream { branch } - } - other => SourceError::StackResolutionFailed { - branch: head_branch.to_string(), - source: other, - }, + assemble_changesets(repo, head_branch, model, UncommittedLayer::Include) + .map_err(map_assemble_err(head_branch)) +} + +/// Maps an [`assemble_changesets`] failure to a [`SourceError`], for the branch named `branch`: +/// a missing upstream becomes [`SourceError::NoUpstream`], anything else +/// [`SourceError::StackResolutionFailed`]. +fn map_assemble_err(branch: &str) -> impl Fn(WorkonError) -> SourceError + '_ { + move |err| match err { + WorkonError::Changeset(ChangesetError::NoUpstream { branch }) => { + SourceError::NoUpstream { branch } + } + other => SourceError::StackResolutionFailed { + branch: branch.to_string(), + source: other, }, - ) + } } /// `` resolution — shape-aware dispatch (ADR-030), checked in order: @@ -161,17 +167,8 @@ fn resolve_ref( } else { UncommittedLayer::Omit }; - return assemble_changesets(repo, &branch_name, StackModel::Graphite, layer).map_err( - |err| match err { - WorkonError::Changeset(ChangesetError::NoUpstream { branch }) => { - SourceError::NoUpstream { branch } - } - other => SourceError::StackResolutionFailed { - branch: branch_name.clone(), - source: other, - }, - }, - ); + return assemble_changesets(repo, &branch_name, StackModel::Graphite, layer) + .map_err(map_assemble_err(&branch_name)); } // Untracked local branch: "what this branch adds" vs its upstream, else the trunk. From 72cc4783eaa541215dd37d7a60685d1ee7331ed4 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 02:23:04 -0400 Subject: [PATCH 066/203] feat(review): resolve PR references as review sources --- git-workon-review/src/error.rs | 34 +++- git-workon-review/src/source.rs | 290 +++++++++++++++++++++++++++++++- 2 files changed, 313 insertions(+), 11 deletions(-) diff --git a/git-workon-review/src/error.rs b/git-workon-review/src/error.rs index ccc96c8..18226a6 100644 --- a/git-workon-review/src/error.rs +++ b/git-workon-review/src/error.rs @@ -152,12 +152,14 @@ pub enum SourceError { }, /// A `` argument (or one side of a `Range`) doesn't rev-parse to anything reviewable — - /// a typo, a deleted branch, a garbage commit-ish. PR resolution lands in CS4; until then a - /// PR-shaped argument falls through to this same honest failure. + /// a typo, a deleted branch, a garbage commit-ish. #[error("cannot resolve '{text}' as a review source")] #[diagnostic( code(workon::review::unresolvable_source), - help("try 'stack', 'uncommitted', a branch/tag/commit, or a..b / a...b range — PRs are not yet supported") + help( + "try 'stack', 'uncommitted', a branch/tag/commit, a..b / a...b range, \ + or a PR reference (pr-123, #123)" + ) )] UnresolvableSource { text: String }, @@ -172,4 +174,30 @@ pub enum SourceError { ) )] NoBaseForBranch { branch: String }, + + /// `check_gh_available` found no working `gh` CLI — a PR reference can't resolve without it, + /// the same requirement `git workon #123`'s own PR workflow has. + #[error("'{text}' is a PR reference, but gh is not available")] + #[diagnostic( + code(workon::review::gh_unavailable), + help("install the gh CLI and run 'gh auth login', then retry") + )] + GhUnavailable { + text: String, + #[source] + source: workon::WorkonError, + }, + + /// Resolving a PR reference failed after `gh` was confirmed available: an unknown PR number, + /// `gh` not authenticated, a fork remote/fetch failure, or a missing base/head ref. + #[error("failed to resolve PR reference '{text}'")] + #[diagnostic( + code(workon::review::pr_resolution_failed), + help("check the PR number and that 'gh auth status' is logged in") + )] + PrResolutionFailed { + text: String, + #[source] + source: workon::WorkonError, + }, } diff --git a/git-workon-review/src/source.rs b/git-workon-review/src/source.rs index 60de389..b0ff518 100644 --- a/git-workon-review/src/source.rs +++ b/git-workon-review/src/source.rs @@ -6,13 +6,16 @@ //! ([`resolve_source`]) is where repo state comes in. //! //! CS3 wires real `` dispatch (shape-aware: Graphite-tracked branch, other branch, -//! bare commit-ish) and `Range` (`a..b` / `a...b`, git-diff semantics). `Pr` still lands with -//! its own changeset — no dead arm here yet (CS4). +//! bare commit-ish) and `Range` (`a..b` / `a...b`, git-diff semantics). CS4 wires `Pr`: any +//! form git-workon-lib's `parse_pr_reference` accepts (`pr-123`, `#123`, `pr#123`, GitHub URLs; +//! a bare number never matches — that spelling stays a `Ref`), reused end-to-end for +//! resolution too (`check_gh_available` → `fetch_pr_metadata` → fork-aware fetch → one +//! committed changeset). use git2::{BranchType, Oid, Repository}; use workon::{ assemble_changesets, get_default_branch, graphite_trunk, ChangesetError, ChangesetSpan, - StackModel, UncommittedLayer, WorkonError, + PrMetadata, StackModel, UncommittedLayer, WorkonError, }; use crate::acquire::uncommitted_changeset; @@ -33,6 +36,10 @@ pub enum RangeDots { /// auto-detect path in `main.rs` and never constructs a `Source` at all. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Source { + /// A PR reference (`pr-123`, `#123`, `pr#123`, a GitHub PR URL — any form + /// `workon::parse_pr_reference` accepts). Carries the source text as typed, for the + /// changeset name; the PR number is re-derived from it at resolution time. + Pr(String), /// The exact bare word `stack`. Stack, /// The exact bare word `uncommitted`. @@ -49,12 +56,18 @@ pub enum Source { } impl Source { - /// Classify `text` per ADR-030's precedence: exact bare keyword, else a range (three-dot - /// checked before two-dot, since `...` contains `..`), else `Ref`. Keywords are checked - /// first — they're exact-bare and contain no dots, so the order between "keyword" and - /// "range" never actually competes, but reading top-to-bottom matches the ADR's precedence - /// list. + /// Classify `text` per ADR-030's precedence: PR reference first (checked via + /// `workon::parse_pr_reference`, pure string parsing — no network, no repo access), then + /// exact bare keyword, else a range (three-dot checked before two-dot, since `...` contains + /// `..`), else `Ref`. A malformed near-PR spelling (`pr-`, `pr-abc`) is `Ok(Err(_))` from the + /// lib parser, not `Ok(Some(_))` — it falls through to the normal precedence chain rather + /// than being force-classified as a broken PR, so it ultimately resolves (or fails) as a + /// `Ref` like any other typo. A bare number (`123`) never matches any of the lib parser's + /// accepted spellings, so it also falls through to `Ref` — no separate digit guard needed. pub fn classify(text: &str) -> Source { + if let Ok(Some(_)) = workon::parse_pr_reference(text) { + return Source::Pr(text.to_string()); + } match text { "stack" => return Source::Stack, "uncommitted" => return Source::Uncommitted, @@ -91,6 +104,7 @@ pub fn resolve_source( source: Source, ) -> Result, SourceError> { match source { + Source::Pr(text) => resolve_pr(repo, text), Source::Stack => resolve_stack(repo, head_branch), Source::Uncommitted => Ok(vec![uncommitted_changeset(head_branch)]), Source::Range { @@ -102,6 +116,110 @@ pub fn resolve_source( } } +/// `Pr` resolution (ADR-030): reuse git-workon-lib's `pr.rs` PR workflow end-to-end, minus the +/// worktree-creation step — review only needs the PR's base and head fetched locally so their +/// merge-base span can be computed, never a branch or worktree. Every failure here is a named, +/// hinted pre-TUI error. The network round-trip (`check_gh_available`, `fetch_pr_metadata`, +/// `fetch_branch`) lives entirely in this function so [`pr_changeset_from_metadata`] can stay a +/// pure git2 mapping, fixture-testable without gh (the real gh path is exercised manually — see +/// the CS4 changeset description). +fn resolve_pr(repo: &Repository, text: String) -> Result, SourceError> { + workon::check_gh_available().map_err(|source| SourceError::GhUnavailable { + text: text.clone(), + source, + })?; + + // `classify` only builds `Source::Pr` from a `parse_pr_reference` `Ok(Some(_))`, so this + // re-parse is infallible in practice; treated as unresolvable rather than unwrapped in case + // a `Source::Pr` is ever constructed some other way. + let pr = workon::parse_pr_reference(&text) + .ok() + .flatten() + .ok_or_else(|| SourceError::UnresolvableSource { text: text.clone() })?; + + let metadata = + workon::fetch_pr_metadata(pr.number).map_err(|source| SourceError::PrResolutionFailed { + text: text.clone(), + source, + })?; + + let head_remote = if metadata.is_fork { + workon::setup_fork_remote(repo, &metadata) + } else { + workon::detect_pr_remote(repo) + } + .map_err(|source| SourceError::PrResolutionFailed { + text: text.clone(), + source, + })?; + workon::fetch_branch(repo, &head_remote, &metadata.head_ref).map_err(|source| { + SourceError::PrResolutionFailed { + text: text.clone(), + source, + } + })?; + + // The base branch is what the PR targets, never a fork branch — always the detected + // upstream/origin remote, regardless of whether the head came from a fork. + let base_remote = + workon::detect_pr_remote(repo).map_err(|source| SourceError::PrResolutionFailed { + text: text.clone(), + source, + })?; + workon::fetch_branch(repo, &base_remote, &metadata.base_ref).map_err(|source| { + SourceError::PrResolutionFailed { + text: text.clone(), + source, + } + })?; + + pr_changeset_from_metadata(repo, &text, &metadata, &head_remote, &base_remote) +} + +/// Map fetched PR metadata to the one committed changeset review renders for it: +/// `merge-base(base tip, head tip)..head`, PR title carried through (ADR-030: "GitHub's own +/// three-dot PR diff"). Pure git2 — no gh, no fetch — assuming `head_remote`/`base_remote` +/// already have `metadata.head_ref`/`metadata.base_ref` as remote-tracking branches (true after +/// [`resolve_pr`]'s fetches, or hand-built in a fixture for testing this half without gh). +fn pr_changeset_from_metadata( + repo: &Repository, + text: &str, + metadata: &PrMetadata, + head_remote: &str, + base_remote: &str, +) -> Result, SourceError> { + let unresolvable = || SourceError::UnresolvableSource { + text: text.to_string(), + }; + + let head_oid = + remote_branch_tip(repo, head_remote, &metadata.head_ref).ok_or_else(unresolvable)?; + let base_tip = + remote_branch_tip(repo, base_remote, &metadata.base_ref).ok_or_else(unresolvable)?; + let base_oid = repo + .merge_base(base_tip, head_oid) + .map_err(|_| unresolvable())?; + + Ok(vec![workon::Changeset { + name: text.to_string(), + span: ChangesetSpan::Committed { + base: base_oid, + head: head_oid, + }, + title: Some(metadata.title.clone()), + current: true, + needs_restack: false, + }]) +} + +/// The tip commit of `refs/remotes/{remote}/{branch}`, or `None` if it isn't a remote-tracking +/// branch that resolves to a commit. +fn remote_branch_tip(repo: &Repository, remote: &str, branch: &str) -> Option { + repo.find_branch(&format!("{remote}/{branch}"), BranchType::Remote) + .ok() + .and_then(|b| b.get().target()) +} + /// `stack` keyword resolution: Graphite metadata when active, otherwise the git-inference arm /// (`StackModel::Git`, first wired into the binary here) — never a silent downgrade to /// `StackModel::None`'s empty result, since the keyword is an explicit ask for the real stack. @@ -395,6 +513,47 @@ mod tests { ); } + #[test] + fn classify_pr_dash_number_is_pr() { + assert_eq!(Source::classify("pr-123"), Source::Pr("pr-123".to_string())); + } + + #[test] + fn classify_hash_number_is_pr() { + assert_eq!(Source::classify("#123"), Source::Pr("#123".to_string())); + } + + #[test] + fn classify_pr_hash_number_is_pr() { + assert_eq!(Source::classify("pr#123"), Source::Pr("pr#123".to_string())); + } + + #[test] + fn classify_github_url_is_pr() { + let url = "https://github.com/owner/repo/pull/123"; + assert_eq!(Source::classify(url), Source::Pr(url.to_string())); + } + + #[test] + fn classify_bare_number_is_ref_not_pr() { + // ADR-030 explicitly excludes a bare number — it could be a branch or an abbreviated + // sha. `workon::parse_pr_reference` already requires a `#`/`pr-`/`pr#` prefix or a + // GitHub URL, so this falls through to `Ref` with no extra guard needed here. + assert_eq!(Source::classify("123"), Source::Ref("123".to_string())); + } + + #[test] + fn classify_malformed_pr_dash_is_ref_not_pr() { + // `pr-` and `pr-abc` look PR-shaped but don't carry a valid number — + // `parse_pr_reference` returns `Err`, not `Ok(Some(_))`, so classify falls through + // rather than force-classifying a broken PR reference. + assert_eq!(Source::classify("pr-"), Source::Ref("pr-".to_string())); + assert_eq!( + Source::classify("pr-abc"), + Source::Ref("pr-abc".to_string()) + ); + } + #[test] fn classify_empty_string_is_ref() { assert_eq!(Source::classify(""), Source::Ref(String::new())); @@ -463,4 +622,119 @@ mod tests { } ); } + + // ── CS4: PR metadata → changeset mapping (the gh-free half of `resolve_pr`) ───────────── + + /// [`pr_changeset_from_metadata`] is the pure git2 half of PR resolution — everything + /// downstream of `fetch_pr_metadata`/`fetch_branch`, which the real `gh` path can't exercise + /// in CI. This fixture stands in for "already fetched": a real (local, file-path) remote, + /// with `fetch_branch` itself used to populate the remote-tracking refs, so the only thing + /// not exercised here is the network round-trip to `gh` and to a non-local remote. + #[test] + fn pr_metadata_maps_to_merge_base_changeset_with_title( + ) -> Result<(), Box> { + use git_workon_fixture::prelude::*; + + // `RemoteSource::from(&Fixture)` only resolves to the bare `.git` dir when + // `fixture.repo()` itself reports bare — true for a bare fixture with NO worktree (a + // worktree checkout is never bare, even off a bare main repo). So this "remote" fixture + // stays worktree-free, and its two divergent branches are built directly with git2 + // rather than via `commit()` (which requires a checked-out worktree path). + let upstream = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .build()?; + let upstream_repo = upstream.repo()?; + let base_commit = upstream_repo.head()?.peel_to_commit()?; + let sig = git2::Signature::now("Test User", "test@example.com")?; + + let mut main_tree = upstream_repo.treebuilder(None)?; + let a_blob = upstream_repo.blob(b"1")?; + main_tree.insert("a.txt", a_blob, 0o100_644)?; + let main_tree_oid = main_tree.write()?; + let main_tree = upstream_repo.find_tree(main_tree_oid)?; + let main_oid = upstream_repo.commit( + Some("refs/heads/main"), + &sig, + &sig, + "on main", + &main_tree, + &[&base_commit], + )?; + + let mut head_tree = upstream_repo.treebuilder(None)?; + let b_blob = upstream_repo.blob(b"1")?; + head_tree.insert("b.txt", b_blob, 0o100_644)?; + let head_tree_oid = head_tree.write()?; + let head_tree = upstream_repo.find_tree(head_tree_oid)?; + let head_oid = upstream_repo.commit( + Some("refs/heads/pr-head"), + &sig, + &sig, + "on pr-head", + &head_tree, + &[&base_commit], + )?; + + let local = FixtureBuilder::new().remote("origin", &upstream).build()?; + let repo = local.repo()?; + workon::fetch_branch(repo, "origin", "main")?; + workon::fetch_branch(repo, "origin", "pr-head")?; + + let metadata = PrMetadata { + number: 123, + title: "Add widget".to_string(), + author: "someone".to_string(), + head_ref: "pr-head".to_string(), + base_ref: "main".to_string(), + is_fork: false, + fork_owner: None, + fork_url: None, + }; + + let changesets = pr_changeset_from_metadata(repo, "pr-123", &metadata, "origin", "origin")?; + assert_eq!(changesets.len(), 1); + assert_eq!(changesets[0].name, "pr-123"); + assert_eq!(changesets[0].title.as_deref(), Some("Add widget")); + assert!(changesets[0].current); + match changesets[0].span { + ChangesetSpan::Committed { base, head } => { + assert_eq!(head, head_oid); + let expected_base = repo.merge_base(main_oid, head_oid)?; + assert_eq!(base, expected_base); + } + other => panic!("expected Committed, got {other:?}"), + } + Ok(()) + } + + /// A missing remote-tracking ref (nothing fetched yet for that branch) is unresolvable, not + /// a panic — guards the "assumes already fetched" precondition documented on + /// [`pr_changeset_from_metadata`]. + #[test] + fn pr_metadata_with_unfetched_head_is_unresolvable() -> Result<(), Box> { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new().build()?; + let repo = fixture.repo()?; + + let metadata = PrMetadata { + number: 123, + title: "Add widget".to_string(), + author: "someone".to_string(), + head_ref: "pr-head".to_string(), + base_ref: "main".to_string(), + is_fork: false, + fork_owner: None, + fork_url: None, + }; + + let err = + pr_changeset_from_metadata(repo, "pr-123", &metadata, "origin", "origin").unwrap_err(); + match err { + SourceError::UnresolvableSource { text } => assert_eq!(text, "pr-123"), + other => panic!("expected UnresolvableSource, got {other:?}"), + } + Ok(()) + } } From 4f00d4d7c1363066605076d49a875dd31e2d10dc Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 09:31:04 -0400 Subject: [PATCH 067/203] fix(pr): fetch PR head and base fresh for review sources --- git-workon-lib/src/pr.rs | 72 +++++++++++++++++++++++++++++++-- git-workon-review/src/source.rs | 16 +++++--- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/git-workon-lib/src/pr.rs b/git-workon-lib/src/pr.rs index 32502d1..0c2424c 100644 --- a/git-workon-lib/src/pr.rs +++ b/git-workon-lib/src/pr.rs @@ -467,11 +467,14 @@ pub fn setup_fork_remote(repo: &Repository, metadata: &PrMetadata) -> Result Result<()> { // Check if branch already exists locally let branch_ref = format!("refs/remotes/{}/{}", remote_name, branch); @@ -480,6 +483,18 @@ pub fn fetch_branch(repo: &Repository, remote_name: &str, branch: &str) -> Resul return Ok(()); } + fetch_branch_fresh(repo, remote_name, branch) +} + +/// Fetch `branch` from `remote_name`, making it available as +/// `refs/remotes/{remote_name}/{branch}`, always — force-updating the tracking ref to the +/// remote's current tip even if it already exists locally. +/// +/// Use this whenever a stale tracking ref would be wrong to review against (e.g. resolving a +/// PR's head and base for `git workon review`): the refspec is already force (`+`), so this +/// never fails on a diverged tracking ref, it just moves it. For the one-time +/// worktree-creation fetch where an existing ref is fine to leave alone, use [`fetch_branch`]. +pub fn fetch_branch_fresh(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> { debug!("Fetching branch {} from remote {}", branch, remote_name); let refspec = format!( @@ -684,6 +699,55 @@ mod tests { ); } + /// `fetch_branch` skips a re-fetch once the tracking ref exists, even when the remote has + /// since moved — right for the one-time worktree-creation fetch, wrong for review, which is + /// why [`fetch_branch_fresh`] exists. Pins both: the existence short-circuit staying put, + /// and `fetch_branch_fresh` force-updating past it via the refspec's `+`. + #[test] + fn fetch_branch_fresh_updates_stale_tracking_ref_but_fetch_branch_does_not( + ) -> std::result::Result<(), Box> { + use git_workon_fixture::prelude::*; + + let upstream = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .build()?; + let upstream_repo = upstream.repo()?; + let old_oid = upstream_repo.head()?.peel_to_commit()?.id(); + + let local = FixtureBuilder::new().remote("origin", &upstream).build()?; + let repo = local.repo()?; + + // First fetch: creates the tracking ref at the remote's current tip. + fetch_branch(repo, "origin", "main")?; + let tracking_ref = "refs/remotes/origin/main"; + assert_eq!(repo.find_reference(tracking_ref)?.target(), Some(old_oid)); + + // The remote moves. + let sig = git2::Signature::now("Test User", "test@example.com")?; + let old_commit = upstream_repo.find_commit(old_oid)?; + let tree = old_commit.tree()?; + let new_oid = upstream_repo.commit( + Some("refs/heads/main"), + &sig, + &sig, + "moved on main", + &tree, + &[&old_commit], + )?; + assert_ne!(new_oid, old_oid); + + // `fetch_branch` sees the ref already exists and leaves it stale. + fetch_branch(repo, "origin", "main")?; + assert_eq!(repo.find_reference(tracking_ref)?.target(), Some(old_oid)); + + // `fetch_branch_fresh` force-updates it to the remote's new tip. + fetch_branch_fresh(repo, "origin", "main")?; + assert_eq!(repo.find_reference(tracking_ref)?.target(), Some(new_oid)); + + Ok(()) + } + // Integration tests requiring gh CLI (marked with #[ignore]) #[test] #[ignore] diff --git a/git-workon-review/src/source.rs b/git-workon-review/src/source.rs index b0ff518..9d44591 100644 --- a/git-workon-review/src/source.rs +++ b/git-workon-review/src/source.rs @@ -120,9 +120,15 @@ pub fn resolve_source( /// worktree-creation step — review only needs the PR's base and head fetched locally so their /// merge-base span can be computed, never a branch or worktree. Every failure here is a named, /// hinted pre-TUI error. The network round-trip (`check_gh_available`, `fetch_pr_metadata`, -/// `fetch_branch`) lives entirely in this function so [`pr_changeset_from_metadata`] can stay a -/// pure git2 mapping, fixture-testable without gh (the real gh path is exercised manually — see -/// the CS4 changeset description). +/// `fetch_branch_fresh`) lives entirely in this function so [`pr_changeset_from_metadata`] can +/// stay a pure git2 mapping, fixture-testable without gh (the real gh path is exercised manually +/// — see the CS4 changeset description). +/// +/// Both refs are fetched with [`workon::fetch_branch_fresh`], not [`workon::fetch_branch`]: +/// review's whole point is freshness, and `fetch_branch`'s existence short-circuit (right for +/// its original one-time worktree-creation fetch) would leave a previously-fetched head stale +/// and — since `refs/remotes/{remote}/{base}` is virtually always already present — would never +/// refresh the base at all, corrupting the merge-base against a stale base tip. fn resolve_pr(repo: &Repository, text: String) -> Result, SourceError> { workon::check_gh_available().map_err(|source| SourceError::GhUnavailable { text: text.clone(), @@ -152,7 +158,7 @@ fn resolve_pr(repo: &Repository, text: String) -> Result, text: text.clone(), source, })?; - workon::fetch_branch(repo, &head_remote, &metadata.head_ref).map_err(|source| { + workon::fetch_branch_fresh(repo, &head_remote, &metadata.head_ref).map_err(|source| { SourceError::PrResolutionFailed { text: text.clone(), source, @@ -166,7 +172,7 @@ fn resolve_pr(repo: &Repository, text: String) -> Result, text: text.clone(), source, })?; - workon::fetch_branch(repo, &base_remote, &metadata.base_ref).map_err(|source| { + workon::fetch_branch_fresh(repo, &base_remote, &metadata.base_ref).map_err(|source| { SourceError::PrResolutionFailed { text: text.clone(), source, From 7e076798ad68fd2c4db526ab485458907ba05062 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 09:33:31 -0400 Subject: [PATCH 068/203] refactor(review): reuse fork dispatch and PR error closure --- git-workon-review/src/source.rs | 48 +++++++++++---------------------- 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/git-workon-review/src/source.rs b/git-workon-review/src/source.rs index 9d44591..cabb26d 100644 --- a/git-workon-review/src/source.rs +++ b/git-workon-review/src/source.rs @@ -135,6 +135,11 @@ fn resolve_pr(repo: &Repository, text: String) -> Result, source, })?; + let fail = |source| SourceError::PrResolutionFailed { + text: text.clone(), + source, + }; + // `classify` only builds `Source::Pr` from a `parse_pr_reference` `Ok(Some(_))`, so this // re-parse is infallible in practice; treated as unresolvable rather than unwrapped in case // a `Source::Pr` is ever constructed some other way. @@ -143,41 +148,20 @@ fn resolve_pr(repo: &Repository, text: String) -> Result, .flatten() .ok_or_else(|| SourceError::UnresolvableSource { text: text.clone() })?; - let metadata = - workon::fetch_pr_metadata(pr.number).map_err(|source| SourceError::PrResolutionFailed { - text: text.clone(), - source, - })?; + let metadata = workon::fetch_pr_metadata(pr.number).map_err(&fail)?; - let head_remote = if metadata.is_fork { - workon::setup_fork_remote(repo, &metadata) + // `setup_fork_remote` already dispatches on `metadata.is_fork` (non-fork → `detect_pr_remote`), + // so only the fork case needs a second, separate lookup for the base remote — a fork's base + // is what the PR targets upstream, never the fork remote itself. + let head_remote = workon::setup_fork_remote(repo, &metadata).map_err(&fail)?; + let base_remote = if metadata.is_fork { + workon::detect_pr_remote(repo).map_err(&fail)? } else { - workon::detect_pr_remote(repo) - } - .map_err(|source| SourceError::PrResolutionFailed { - text: text.clone(), - source, - })?; - workon::fetch_branch_fresh(repo, &head_remote, &metadata.head_ref).map_err(|source| { - SourceError::PrResolutionFailed { - text: text.clone(), - source, - } - })?; + head_remote.clone() + }; - // The base branch is what the PR targets, never a fork branch — always the detected - // upstream/origin remote, regardless of whether the head came from a fork. - let base_remote = - workon::detect_pr_remote(repo).map_err(|source| SourceError::PrResolutionFailed { - text: text.clone(), - source, - })?; - workon::fetch_branch_fresh(repo, &base_remote, &metadata.base_ref).map_err(|source| { - SourceError::PrResolutionFailed { - text: text.clone(), - source, - } - })?; + workon::fetch_branch_fresh(repo, &head_remote, &metadata.head_ref).map_err(&fail)?; + workon::fetch_branch_fresh(repo, &base_remote, &metadata.base_ref).map_err(&fail)?; pr_changeset_from_metadata(repo, &text, &metadata, &head_remote, &base_remote) } From 9a606140b63f0d989c5155d06e39ecc52efe65a0 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 09:47:21 -0400 Subject: [PATCH 069/203] fix(review): refresh no-ops for a PR review source --- git-workon-review/src/app.rs | 69 +++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 2363d6b..781fc76 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1032,10 +1032,12 @@ impl App { /// Dispatches on [`Self::review_source`] (M7 CS2 fix): a no-argument launch (`None`) re-runs /// today's auto-detect ([`crate::acquire::resolve_changesets`]); an explicit-source launch /// (`Some`) re-runs [`crate::source::resolve_source`] against THAT source, never auto-detect - /// — every CS2 source variant (`Stack`, `Uncommitted`) is offline, so re-resolving on every - /// refresh (manual `r` and the tick-driven index watcher alike) is cheap and safe. Without - /// this, both refresh triggers would silently swap an explicit review (e.g. `uncommitted`) - /// for the current `HEAD`'s auto-detected state. + /// — every ref-shaped source variant (`Stack`, `Uncommitted`, `Ref`, `Range`) is offline, + /// so re-resolving on every refresh (manual `r` and the tick-driven index watcher alike) is + /// cheap and safe. Without this, both refresh triggers would silently swap an explicit + /// review (e.g. `uncommitted`) for the current `HEAD`'s auto-detected state. + /// [`Source::Pr`] is the one exception: it resolves over the network (gh metadata + fetch), + /// so refresh is a no-op for it — see the match arm below. pub fn refresh(&mut self) { let Some(head_branch) = self .repo @@ -1050,6 +1052,11 @@ impl App { let changesets = match &self.review_source { None => crate::acquire::resolve_changesets(&self.repo, &head_branch) .map_err(|err| err.to_string()), + // A PR review is committed-only: nothing it renders depends on the index/worktree + // state that refresh exists to pick up, and re-resolving would hit the network + // (gh metadata + fetch) on every tick-driven refresh. Remote freshness is a + // re-launch, not a refresh. + Some(Source::Pr(_)) => return, Some(source) => resolve_source(&self.repo, &head_branch, source.clone()) .map_err(|err| err.to_string()), }; @@ -3694,6 +3701,60 @@ mod tests { assert_eq!(app.cur().cs.span, ChangesetSpan::Uncommitted); } + /// A PR-sourced review must survive refresh untouched: re-resolving would hit the network + /// (gh + fetch), so [`App::refresh`] no-ops for [`Source::Pr`]. The fixture has no PR and no + /// gh — if refresh DID try to re-resolve, `resolve_pr` would fail and raise a "refresh + /// failed" notice; asserting no notice (and unchanged views) pins the no-op. + #[test] + fn refresh_is_a_no_op_for_a_pr_source() { + use crate::source::Source; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + fixture + .commit("main") + .file("a.txt", "one\n") + .create("first") + .unwrap(); + fixture + .commit("main") + .file("a.txt", "two\n") + .create("second") + .unwrap(); + let repo = fixture.repo().unwrap(); + + let head = repo.head().unwrap().peel_to_commit().unwrap(); + let base = head.parent(0).unwrap(); + let cs = workon::Changeset { + name: "pr-1".to_string(), + span: ChangesetSpan::Committed { + base: base.id(), + head: head.id(), + }, + title: Some("a pr".to_string()), + current: true, + needs_restack: false, + }; + let diff = crate::acquire::diff_changeset(repo, &cs).unwrap(); + let views = vec![ChangesetView::from_changeset_diff(cs, diff)]; + + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, views); + app.set_review_source(Source::Pr("pr-1".to_string())); + app.open_current(); + + app.refresh(); + + assert_eq!(app.changeset_count(), 1); + assert_eq!(app.cur().cs.name, "pr-1"); + assert!( + app.notice.is_none(), + "a PR-source refresh must no-op, not attempt (and fail) a network re-resolution" + ); + } + // ---- M4 index watcher (`on_tick`) ------------------------------------------------------- /// Stage `path` in the fixture's index, exactly as an external `git add` would — the write From fc88b0a80cbef4a03ba9a1b35f35a29ec1c94f4b Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 02:55:51 -0400 Subject: [PATCH 070/203] feat(completions): review source completion and delegation --- git-workon-fixture/src/path_stub.rs | 22 ++++++ git-workon-review/src/main.rs | 7 +- git-workon-review/src/source.rs | 100 ++++++++++++++++++++++++ git-workon-review/tests/cli.rs | 108 ++++++++++++++++++++++---- git-workon/src/completers.rs | 97 ++++++++++++++++++++++- git-workon/src/main.rs | 6 ++ git-workon/tests/suite/completions.rs | 56 +++++++++++++ 7 files changed, 377 insertions(+), 19 deletions(-) diff --git a/git-workon-fixture/src/path_stub.rs b/git-workon-fixture/src/path_stub.rs index 441d564..4fd3615 100644 --- a/git-workon-fixture/src/path_stub.rs +++ b/git-workon-fixture/src/path_stub.rs @@ -35,6 +35,16 @@ impl PathStub { Ok(self) } + /// Symlink a real executable (e.g. another workspace binary's `CARGO_BIN_EXE_*` path) into + /// the stub directory as `git-workon-`, so a test can drive genuine external-binary + /// behavior (not just canned `arg:`/`cwd:` stub output) through the same PATH-dispatch or + /// PATH-completion surface `command` exercises. + pub fn command_exe(self, name: &str, exe: &std::path::Path) -> Result { + let path = self.dir.path().join(format!("git-workon-{name}")); + symlink_exe(exe, &path)?; + Ok(self) + } + /// `PATH` value with the stub directory prepended to the current process's `PATH`, so a /// stub shadows nothing else already on `PATH` unless intended (see built-in precedence). pub fn path(&self) -> String { @@ -61,3 +71,15 @@ fn set_executable(path: &PathBuf) -> Result<()> { fn set_executable(_path: &PathBuf) -> Result<()> { Ok(()) } + +#[cfg(unix)] +fn symlink_exe(exe: &std::path::Path, link: &std::path::Path) -> Result<()> { + std::os::unix::fs::symlink(exe, link)?; + Ok(()) +} + +#[cfg(not(unix))] +fn symlink_exe(exe: &std::path::Path, link: &std::path::Path) -> Result<()> { + std::fs::copy(exe, link)?; + Ok(()) +} diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 3beb772..c69380b 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -1,6 +1,7 @@ mod tui; use clap::{CommandFactory, Parser}; +use clap_complete::engine::ArgValueCompleter; use clap_complete::env::CompleteEnv; use git2::Repository; use miette::{IntoDiagnostic, Result}; @@ -8,7 +9,7 @@ use workon_review::acquire::{diff_changeset, resolve_changesets}; use workon_review::app::{App, ChangesetView, Severity}; use workon_review::config::{self, ReviewConfig}; use workon_review::keymap::Keymap; -use workon_review::source::{resolve_source, Source}; +use workon_review::source::{complete_source, resolve_source, Source}; use workon_review::terminal_query; use workon_review::theme::Palette; @@ -17,8 +18,8 @@ use workon_review::theme::Palette; #[clap(about, author, bin_name = env!("CARGO_PKG_NAME"), version)] struct Cli { /// What to review: stack, uncommitted, a ref (branch/tag/commit), a..b / a...b range, or - /// (CS4) a PR reference - #[arg(value_name = "SOURCE")] + /// a PR reference + #[arg(value_name = "SOURCE", add = ArgValueCompleter::new(complete_source))] source: Option, } diff --git a/git-workon-review/src/source.rs b/git-workon-review/src/source.rs index cabb26d..bdb6b20 100644 --- a/git-workon-review/src/source.rs +++ b/git-workon-review/src/source.rs @@ -12,6 +12,9 @@ //! resolution too (`check_gh_available` → `fetch_pr_metadata` → fork-aware fetch → one //! committed changeset). +use std::ffi::OsStr; + +use clap_complete::engine::CompletionCandidate; use git2::{BranchType, Oid, Repository}; use workon::{ assemble_changesets, get_default_branch, graphite_trunk, ChangesetError, ChangesetSpan, @@ -450,6 +453,84 @@ fn trunk_commit_oid(repo: &Repository) -> Option { revparse_to_commit(repo, &name) } +/// Dynamic `[SOURCE]` completion candidates (ADR-030 "Completion" section, CS5): the `stack` / +/// `uncommitted` keywords, plus local branch and tag names via offline git2 ref enumeration — +/// never a PR number (network stays out of the TAB hot path). When `current` contains `..` or +/// `...`, only the right-hand side is a ref candidate; each is emitted prefixed with the +/// left-hand text (dots included) so the shell's own prefix filtering keeps working on the full +/// word (e.g. typing `main..fe` offers `main..feature-x`, not just `feature-x`). +/// +/// Failure-safe by construction: [`Repository::discover`] failing (not a repo, or any other git +/// error) simply skips the ref arms below, leaving keyword candidates — this must never panic or +/// surface an error into the completion path (a broken `TAB` is worse than an incomplete one). +pub fn complete_source(current: &OsStr) -> Vec { + let Some(current) = current.to_str() else { + return Vec::new(); + }; + + let (prefix, ref_prefix) = split_range_rhs(current); + let mut candidates = Vec::new(); + + // Keywords only make sense as the bare word itself — never after a `..`/`...` split. + if prefix.is_empty() { + for (keyword, help) in [ + ("stack", "Review the whole Graphite/git-inferred stack"), + ("uncommitted", "Review only uncommitted changes"), + ] { + if keyword.starts_with(ref_prefix) { + candidates.push(CompletionCandidate::new(keyword).help(Some(help.into()))); + } + } + } + + if let Ok(repo) = Repository::discover(".") { + for name in local_branch_and_tag_names(&repo) { + if name.starts_with(ref_prefix) { + candidates.push(CompletionCandidate::new(format!("{prefix}{name}"))); + } + } + } + + candidates +} + +/// Split `text` on its last dot-range separator (`...` checked before `..`, matching +/// [`Source::classify`]'s precedence): `(left-including-dots, right-hand-partial)`. No dots at +/// all yields `("", text)` — the whole word is the partial being completed. +fn split_range_rhs(text: &str) -> (&str, &str) { + if let Some(idx) = text.find("...") { + (&text[..idx + 3], &text[idx + 3..]) + } else if let Some(idx) = text.find("..") { + (&text[..idx + 2], &text[idx + 2..]) + } else { + ("", text) + } +} + +/// Local branch and tag names, offline (no network, no remote enumeration) — any git2 error +/// along the way degrades to whatever was already collected rather than propagating. +fn local_branch_and_tag_names(repo: &Repository) -> Vec { + let mut names = Vec::new(); + + if let Ok(branches) = repo.branches(Some(BranchType::Local)) { + for (branch, _) in branches.filter_map(Result::ok) { + if let Ok(Some(name)) = branch.name() { + names.push(name.to_string()); + } + } + } + + if let Ok(tags) = repo.tag_names(None) { + names.extend( + tags.iter() + .filter_map(|t| t.ok().flatten()) + .map(str::to_string), + ); + } + + names +} + #[cfg(test)] mod tests { use super::*; @@ -613,6 +694,25 @@ mod tests { ); } + // ── CS5: SOURCE completion — `split_range_rhs` (the pure half of `complete_source`) ───── + + #[test] + fn split_range_rhs_no_dots_is_whole_word() { + assert_eq!(split_range_rhs("main"), ("", "main")); + assert_eq!(split_range_rhs(""), ("", "")); + } + + #[test] + fn split_range_rhs_two_dot_splits_after_dots() { + assert_eq!(split_range_rhs("main..fe"), ("main..", "fe")); + assert_eq!(split_range_rhs("main.."), ("main..", "")); + } + + #[test] + fn split_range_rhs_three_dot_wins_over_two_dot() { + assert_eq!(split_range_rhs("main...fe"), ("main...", "fe")); + } + // ── CS4: PR metadata → changeset mapping (the gh-free half of `resolve_pr`) ───────────── /// [`pr_changeset_from_metadata`] is the pure git2 half of PR resolution — everything diff --git a/git-workon-review/tests/cli.rs b/git-workon-review/tests/cli.rs index fbec3c5..9a31edc 100644 --- a/git-workon-review/tests/cli.rs +++ b/git-workon-review/tests/cli.rs @@ -28,22 +28,102 @@ fn help_shows_usage_and_succeeds() { .stdout(predicate::str::contains("git-workon-review")); } +/// Drive clap_complete's dynamic `COMPLETE=bash` protocol for `git-workon-review ` (mirrors +/// `git-workon/tests/completions.rs`'s `bash_candidates` helper) and return the emitted candidate +/// values, one per line (no `_CLAP_IFS` override means `write_complete` falls back to `\n`). +fn bash_candidates(cwd: &std::path::Path, word: &str) -> Vec { + let output = cargo_bin_cmd!("git-workon-review") + .env("COMPLETE", "bash") + .env("_CLAP_COMPLETE_INDEX", "1") + .current_dir(cwd) + .args(["--", "git-workon-review", word]) + .output() + .expect("completion invocation"); + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::to_string) + .filter(|s| !s.is_empty()) + .collect() +} + +/// ADR-030 "Completion" section (CS5): the `stack`/`uncommitted` keywords plus local branch and +/// tag names, offline, via git2 ref enumeration — this is the SOURCE positional's dynamic +/// completer, exercised through the same `COMPLETE=bash` protocol M6 wired the binary to answer. +#[test] +fn source_completion_offers_keywords_and_local_refs() { + let fixture = FixtureBuilder::new() + .default_branch("main") + .branch("feature-x") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + let head = repo.head().unwrap().peel_to_commit().unwrap(); + repo.tag_lightweight("v1", head.as_object(), false).unwrap(); + + let candidates = bash_candidates(repo.workdir().unwrap(), ""); + + assert!(candidates.contains(&"stack".to_string()), "{candidates:?}"); + assert!( + candidates.contains(&"uncommitted".to_string()), + "{candidates:?}" + ); + assert!( + candidates.contains(&"feature-x".to_string()), + "{candidates:?}" + ); + assert!(candidates.contains(&"v1".to_string()), "{candidates:?}"); +} + +/// A word containing `..`/`...` only completes the right-hand ref, reassembled with the +/// left-hand text (dots included) so shell prefix-matching keeps working on the whole word. +#[test] +fn source_completion_completes_range_rhs_with_lhs_prefix() { + let fixture = FixtureBuilder::new() + .default_branch("main") + .branch("feature-x") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + + let candidates = bash_candidates(repo.workdir().unwrap(), "main..fe"); + + assert!( + candidates.contains(&"main..feature-x".to_string()), + "{candidates:?}" + ); + // Never a bare ref without the `main..` prefix, and never a keyword after a dot-range. + assert!( + !candidates.contains(&"feature-x".to_string()), + "{candidates:?}" + ); + assert!(!candidates.contains(&"stack".to_string()), "{candidates:?}"); +} + /// The binary answers the `COMPLETE=` dynamic-completion protocol (clap_complete's /// `CompleteEnv`), so git-workon can delegate `git workon review ` completion to it (M6 CS3). -/// The review `Cli` has no args of its own yet, so the completer generates no candidates and exits -/// 2 ("no completion generated") — but the load-bearing contract is that `COMPLETE` mode -/// short-circuits into the completer *before* repository discovery or the TUI. Running from an -/// empty non-repo dir makes that concrete: an unwired binary would instead fail repo discovery; -/// getting clap_complete's own exit path proves the responder is in place. When M7 gives the -/// binary a real subcommand (`mcp`), upgrade this to assert that candidate. +/// A non-repo cwd degrades to keyword-only candidates (ADR-030: any git error → keywords only, +/// never a completion-path error) rather than failing repo discovery — the load-bearing contract +/// is that `COMPLETE` mode short-circuits into the completer *before* that discovery even runs. #[test] -fn responds_to_complete_env_protocol_before_repo_discovery() { +fn non_repo_cwd_completes_keywords_only_without_error() { let non_repo = assert_fs::TempDir::new().unwrap(); - let mut cmd = cargo_bin_cmd!("git-workon-review"); - cmd.env("COMPLETE", "bash") - .current_dir(&non_repo) - .args(["--", "git-workon-review", ""]) - .assert() - .code(2) - .stderr(predicate::str::contains("completion")); + + let candidates = bash_candidates(&non_repo, ""); + + assert!(candidates.contains(&"stack".to_string()), "{candidates:?}"); + assert!( + candidates.contains(&"uncommitted".to_string()), + "{candidates:?}" + ); + // No ref candidates from a non-repo cwd — only the two keywords (plus clap's own + // `--help`/`--version`, unrelated to the ref-enumeration arm under test here). + assert_eq!( + candidates + .iter() + .filter(|c| !c.starts_with('-')) + .cloned() + .collect::>(), + vec!["stack".to_string(), "uncommitted".to_string()], + "a non-repo cwd must never surface ref candidates" + ); } diff --git a/git-workon/src/completers.rs b/git-workon/src/completers.rs index 2377ab8..c5fc68f 100644 --- a/git-workon/src/completers.rs +++ b/git-workon/src/completers.rs @@ -1,8 +1,8 @@ -use std::ffi::OsStr; +use std::ffi::{OsStr, OsString}; use std::path::Path; use clap::builder::StyledStr; -use clap::Command; +use clap::{Command, CommandFactory}; use clap_complete::engine::{ArgValueCompleter, CompletionCandidate}; use workon::WorktreeDescriptor; @@ -89,6 +89,99 @@ fn augment_external_subcommands(cmd: Command) -> Command { }) } +/// Sub-delegate `git-workon ` completion to `git-workon-`'s own +/// `COMPLETE=` responder — the M6 CS3-deferred seam, wired here per ADR-030 CS5. +/// +/// `augment_external_subcommands` (above) only adds a bare stub `Command` for each PATH-discovered +/// external, with no argument definitions of its own — clap_complete's engine has no notion that +/// the stub actually stands in for a whole other program, so anything typed after the external's +/// name would otherwise complete against nothing (verified manually: `git workon review ` +/// offered only global flags before this). This function runs *before* `CompleteEnv`'s own +/// dispatch in `main`, so it can intercept that case and hand off instead. +/// +/// It re-derives the shell's word list and completion index the same way `clap_complete`'s own +/// bash/elvish adapters do (`_CLAP_COMPLETE_INDEX`; zsh/fish don't set that var and always mean +/// "the last word", so that's the fallback). If the first non-flag word after the program name +/// resolves to a `git-workon-` executable on `$PATH` (and isn't a known built-in — a +/// built-in's own `Cli` already completes itself) *and* the word actually being completed sits +/// after it, this re-invokes that executable under the identical protocol: the leading +/// ` ` words collapse into one placeholder word (`git-workon-`, mirroring +/// how the external is invoked for real by `dispatch::try_dispatch`) and the completion index +/// shifts down by however many leading words were collapsed away. The external's stdout — already +/// shell-formatted by its own `CompleteEnv` responder — is copied through verbatim, and this +/// process exits with the external's exit code. +/// +/// A no-op (returns without printing or exiting) whenever `COMPLETE` isn't set, there's no word +/// after the program name, the completing index lands on the subcommand slot itself (that's +/// still a top-level candidate list, not a delegation target), the leading word is a known +/// built-in, or nothing matching is found on `$PATH` — every one of those falls through to +/// `CompleteEnv`'s normal dispatch in `main`. +pub fn try_delegate_external_completion() { + let Some(shell) = std::env::var_os("COMPLETE") else { + return; + }; + if shell.is_empty() || shell == "0" { + return; + } + + let args: Vec = std::env::args_os().collect(); + let Some(dash_dash) = args.iter().position(|a| a == "--") else { + return; + }; + let words = &args[dash_dash + 1..]; + if words.len() < 2 { + return; // nothing after the program-name word to delegate + } + + // Mirrors clap_complete's own env adapters: bash/elvish read `_CLAP_COMPLETE_INDEX`; zsh/fish + // always treat the last word as the one being completed. + let index = std::env::var("_CLAP_COMPLETE_INDEX") + .ok() + .and_then(|i| i.parse::().ok()) + .unwrap_or(words.len() - 1); + + // The first non-flag word after the program name (index 0) is the subcommand candidate. + let Some(subcmd_pos) = words[1..] + .iter() + .position(|w| !w.to_str().is_some_and(|s| s.starts_with('-'))) + .map(|i| i + 1) + else { + return; + }; + if index <= subcmd_pos { + return; // completing the subcommand slot itself, not a word after it + } + + let Some(name) = words[subcmd_pos].to_str() else { + return; + }; + let known = crate::dispatch::known_subcommands(&crate::cli::Cli::command()); + if known.contains(name) { + return; // a built-in owns this name; its own Cli completes it + } + let Some(exe) = crate::dispatch::find_external(name) else { + return; // no matching external on PATH + }; + + let mut delegated_words: Vec = vec![OsString::from(format!("git-workon-{name}"))]; + delegated_words.extend(words[subcmd_pos + 1..].iter().cloned()); + let delegated_index = index - subcmd_pos; + + let output = std::process::Command::new(&exe) + .env("COMPLETE", &shell) + .env("_CLAP_COMPLETE_INDEX", delegated_index.to_string()) + .arg("--") + .args(&delegated_words) + .output(); + + let Ok(output) = output else { + std::process::exit(0); // fail closed: no candidates rather than a broken TAB + }; + use std::io::Write as _; + let _ = std::io::stdout().write_all(&output.stdout); + std::process::exit(output.status.code().unwrap_or(0)); +} + pub fn augment(cmd: Command) -> Command { let cmd = augment_external_subcommands(cmd); cmd.mut_arg("name", |a| { diff --git a/git-workon/src/main.rs b/git-workon/src/main.rs index 06fcce1..00a4c83 100644 --- a/git-workon/src/main.rs +++ b/git-workon/src/main.rs @@ -18,6 +18,12 @@ use crate::cmd::Run; use crate::json::worktree_to_json; fn main() -> Result<()> { + // Must run before `CompleteEnv`'s own dispatch: an external subcommand's stub `Command` (see + // `completers::augment`) carries no argument definitions, so `CompleteEnv` alone can't + // complete anything typed after it. This hands those words off to the external's own + // `COMPLETE=` responder instead (M6 CS3's deferred seam; ADR-030 CS5), and is a no-op in + // every other case (see its doc comment). + completers::try_delegate_external_completion(); CompleteEnv::with_factory(|| completers::augment(Cli::command())).complete(); dispatch::try_dispatch(&dispatch::known_subcommands(&Cli::command())); diff --git a/git-workon/tests/suite/completions.rs b/git-workon/tests/suite/completions.rs index f15c844..991b5e3 100644 --- a/git-workon/tests/suite/completions.rs +++ b/git-workon/tests/suite/completions.rs @@ -109,6 +109,62 @@ fn tab_lists_external_subcommands_from_path() { ); } +/// The sibling `git-workon-review` binary, built alongside this test binary as part of the +/// workspace (`cargo test --workspace` / `-p git-workon` after a workspace build both produce +/// it). Located via the workspace's `target//` (one level up from this crate's +/// `CARGO_MANIFEST_DIR`) rather than pulled in as a Cargo dev-dependency, which would drag +/// `git-workon-review`'s whole tree-sitter-heavy dependency tree into every `git-workon` test +/// build for one delegation test. NOT `current_exe()`-relative: this repo shares cargo's +/// intermediate build artifacts across worktrees (a `build-dir` override in `.cargo/config.toml`), +/// so test binaries themselves live under that shared dir while final binaries still land in +/// this worktree's own `target//`. +fn review_binary_path() -> std::path::PathBuf { + let profile = if cfg!(debug_assertions) { + "debug" + } else { + "release" + }; + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .join("target") + .join(profile) + .join("git-workon-review") +} + +/// ADR-030 CS5 / M6 CS3's deferred seam: `git workon review ` shells out to the review +/// binary's own `COMPLETE=` responder rather than completing against review's argument-less stub +/// `Command` (`augment_external_subcommands`). Uses the *real* compiled `git-workon-review` +/// (via `PathStub::command_exe`, not the canned `arg:`/`cwd:` script `command` writes) so the +/// candidates asserted here — `stack`/`uncommitted` — are genuinely the review binary's own SOURCE +/// completer output, proving the index-shifted shell-out end to end. +#[test] +fn tab_after_review_subcommand_delegates_to_review_binary_completer() { + let review_exe = review_binary_path(); + assert!( + review_exe.is_file(), + "expected a sibling git-workon-review binary at {review_exe:?} \ + (built as part of a workspace build/test run)" + ); + + let stub = PathStub::new() + .unwrap() + .command_exe("review", &review_exe) + .unwrap(); + + // `git workon review ` — index 2 is the (empty) word after `review`. + let candidates = bash_candidates(&stub.path(), &["git-workon", "review", ""], 2); + + assert!( + candidates.iter().any(|c| c == "stack"), + "expected the review binary's own `stack` keyword candidate, delegated through: {candidates:?}" + ); + assert!( + candidates.iter().any(|c| c == "uncommitted"), + "expected the review binary's own `uncommitted` keyword candidate, delegated through: {candidates:?}" + ); +} + #[test] fn external_subcommand_does_not_shadow_a_builtin_in_completion() { // A `git-workon-list` stub must not produce a duplicate `list` candidate — the built-in owns From 4f1dabf04ee06f6e1989634041f8d3ab7a6a2b89 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 09:40:57 -0400 Subject: [PATCH 071/203] fix(completions): gate delegation to known responders, null stdin --- git-workon/src/completers.rs | 38 ++++++++++++++++++++------- git-workon/tests/suite/completions.rs | 21 +++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/git-workon/src/completers.rs b/git-workon/src/completers.rs index c5fc68f..1dd5bae 100644 --- a/git-workon/src/completers.rs +++ b/git-workon/src/completers.rs @@ -89,6 +89,18 @@ fn augment_external_subcommands(cmd: Command) -> Command { }) } +/// First-party externals known to speak the `clap_complete` `COMPLETE=` responder protocol. +/// +/// Delegation (below) has to *execute* the external to get its completions — there is no way to +/// probe "does this binary support `COMPLETE=`" without running it, and a plain user script (the +/// external-subcommand surface explicitly supports those; see `dispatch.rs` and the +/// `PathStub::command` tests) ignores `COMPLETE` entirely and just runs, turning a TAB press into +/// an arbitrary side-effecting execution with its normal stdout misread as completion candidates. +/// ADR-030 only promises this delegation for the review binary, so the allowlist starts there. +/// A future git-config allowlist (`workon.*`, ADR-006) can let users opt other externals in +/// deliberately — extend this list (or make it configurable) when that lands. +const DELEGATED_EXTERNALS: &[&str] = &["review"]; + /// Sub-delegate `git-workon ` completion to `git-workon-`'s own /// `COMPLETE=` responder — the M6 CS3-deferred seam, wired here per ADR-030 CS5. /// @@ -102,20 +114,22 @@ fn augment_external_subcommands(cmd: Command) -> Command { /// It re-derives the shell's word list and completion index the same way `clap_complete`'s own /// bash/elvish adapters do (`_CLAP_COMPLETE_INDEX`; zsh/fish don't set that var and always mean /// "the last word", so that's the fallback). If the first non-flag word after the program name -/// resolves to a `git-workon-` executable on `$PATH` (and isn't a known built-in — a -/// built-in's own `Cli` already completes itself) *and* the word actually being completed sits -/// after it, this re-invokes that executable under the identical protocol: the leading -/// ` ` words collapse into one placeholder word (`git-workon-`, mirroring -/// how the external is invoked for real by `dispatch::try_dispatch`) and the completion index -/// shifts down by however many leading words were collapsed away. The external's stdout — already -/// shell-formatted by its own `CompleteEnv` responder — is copied through verbatim, and this -/// process exits with the external's exit code. +/// names a subcommand in `DELEGATED_EXTERNALS` (see its doc comment for why delegation is gated at +/// all) that also resolves to a `git-workon-` executable on `$PATH` (and isn't a known +/// built-in — a built-in's own `Cli` already completes itself) *and* the word actually being +/// completed sits after it, this re-invokes that executable under the identical protocol: the +/// leading ` ` words collapse into one placeholder word (`git-workon-`, +/// mirroring how the external is invoked for real by `dispatch::try_dispatch`) and the completion +/// index shifts down by however many leading words were collapsed away. Stdin is nulled for the +/// delegated process — an external that prompts on stdin must not be able to block the user's +/// shell on a TAB press. The external's stdout — already shell-formatted by its own `CompleteEnv` +/// responder — is copied through verbatim, and this process exits with the external's exit code. /// /// A no-op (returns without printing or exiting) whenever `COMPLETE` isn't set, there's no word /// after the program name, the completing index lands on the subcommand slot itself (that's /// still a top-level candidate list, not a delegation target), the leading word is a known -/// built-in, or nothing matching is found on `$PATH` — every one of those falls through to -/// `CompleteEnv`'s normal dispatch in `main`. +/// built-in, the leading word isn't in `DELEGATED_EXTERNALS`, or nothing matching is found on +/// `$PATH` — every one of those falls through to `CompleteEnv`'s normal dispatch in `main`. pub fn try_delegate_external_completion() { let Some(shell) = std::env::var_os("COMPLETE") else { return; @@ -159,6 +173,9 @@ pub fn try_delegate_external_completion() { if known.contains(name) { return; // a built-in owns this name; its own Cli completes it } + if !DELEGATED_EXTERNALS.contains(&name) { + return; // protocol support isn't known/promised for this external; don't execute it + } let Some(exe) = crate::dispatch::find_external(name) else { return; // no matching external on PATH }; @@ -170,6 +187,7 @@ pub fn try_delegate_external_completion() { let output = std::process::Command::new(&exe) .env("COMPLETE", &shell) .env("_CLAP_COMPLETE_INDEX", delegated_index.to_string()) + .stdin(std::process::Stdio::null()) .arg("--") .args(&delegated_words) .output(); diff --git a/git-workon/tests/suite/completions.rs b/git-workon/tests/suite/completions.rs index 991b5e3..2a1993d 100644 --- a/git-workon/tests/suite/completions.rs +++ b/git-workon/tests/suite/completions.rs @@ -165,6 +165,27 @@ fn tab_after_review_subcommand_delegates_to_review_binary_completer() { ); } +/// Security regression: only `DELEGATED_EXTERNALS` (`completers.rs`) are re-invoked under the +/// `COMPLETE=` protocol. A non-allowlisted external is a plain user script that has no idea what +/// `COMPLETE` means — it would just run for real on every TAB press, its normal stdout misread as +/// completion candidates and its side effects fired. `PathStub::command`'s canned script prints +/// distinctive `arg:`/`cwd:` lines to stdout when executed; asserting those never appear (and the +/// process still exits cleanly, falling through to the stub top-level candidate) proves the stub +/// was never invoked. +#[test] +fn tab_after_non_allowlisted_external_does_not_execute_it() { + let stub = PathStub::new().unwrap().command("greet").unwrap(); + + let candidates = bash_candidates(&stub.path(), &["git-workon", "greet", ""], 2); + + assert!( + candidates + .iter() + .all(|c| !c.starts_with("arg:") && !c.starts_with("cwd:")), + "non-allowlisted external must never be executed for completion: {candidates:?}" + ); +} + #[test] fn external_subcommand_does_not_shadow_a_builtin_in_completion() { // A `git-workon-list` stub must not produce a duplicate `list` candidate — the built-in owns From f42de99b96c47c4da6460cb00577753a1d589e08 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 12:03:05 -0400 Subject: [PATCH 072/203] perf(review): coalesce buffered nav input in the event loop --- git-workon-review/src/app.rs | 8 +- git-workon-review/src/tui.rs | 555 +++++++++++++++++++++++++++++++++-- 2 files changed, 543 insertions(+), 20 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 781fc76..7430cc2 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1210,8 +1210,12 @@ impl App { } /// Read-only access to file `idx`'s already-loaded [`FileView`] for `role` (`None` if the role - /// has no change for the file, or it isn't loaded yet). - pub(crate) fn role_view_ref(&self, idx: usize, role: Role) -> Option<&FileView> { + /// has no change for the file, or it isn't loaded yet). `pub` (not `pub(crate)`) so the + /// separate `git-workon-review` bin crate's `tui.rs` tests can assert a file was — or, more + /// importantly, was NOT — loaded without visiting it (CS2's event-coalescing regression + /// test); read-only and does not touch `open_current`/`ensure_loaded`/`outline_move_by`'s + /// eager-load semantics. + pub fn role_view_ref(&self, idx: usize, role: Role) -> Option<&FileView> { self.views_for(role).get(idx).and_then(|v| v.as_ref()) } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index e20e646..e0e532c 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -52,6 +52,30 @@ pub fn next_event(timeout: Duration) -> io::Result> { }) } +/// Cap on how many events [`drain_pending`] batches per iteration — leftover input past this +/// count is simply picked up by the next iteration's `next_event` call. +const MAX_DRAIN_BATCH: usize = 128; + +/// Drain all immediately-available terminal events into `batch`, mapping them exactly like +/// [`next_event`]'s read arm (key-press and resize map; release/repeat/mouse/paste/focus are +/// skipped, not pushed). Unlike calling `next_event(Duration::ZERO)` in a loop, a not-ready poll +/// here simply stops draining — it must NOT fabricate a `Tick`, since `next_event`'s `!poll` arm +/// exists solely to give the loop its regular redraw beat on a real timeout, and reusing it here +/// would inject a spurious tick at the end of every drain. +fn drain_pending(batch: &mut Vec) -> io::Result<()> { + while batch.len() < MAX_DRAIN_BATCH { + if !event::poll(Duration::ZERO)? { + break; + } + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => batch.push(AppEvent::Key(key)), + Event::Resize(w, h) => batch.push(AppEvent::Resize(w, h)), + _ => {} + } + } + Ok(()) +} + /// The action a mapped key requests, independent of any [`App`] — kept separate from /// [`map_key`]'s dispatch so the mapping itself is unit-testable without building an `App`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -195,6 +219,43 @@ fn apply_action(app: &mut App, action: Action) -> bool { false } +/// The result of resolving a `Key` event through the non-modal cascade (see [`resolve_key`]): +/// either the key was fully handled inline (the selection-Esc guard cancelled the selection), or +/// it resolved to an [`Action`] still waiting to be applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeyOutcome { + Handled, + Action(Action), +} + +/// Resolve one `Key` event to a [`KeyOutcome`], given the caller has already ruled out the two +/// modal cases (a pending discard confirm, the help overlay) — this is cases 3-5 of `update`'s +/// documented Esc-precedence cascade, extracted so [`update`] and [`update_batch`] share the exact +/// same resolution instead of duplicating it. +/// +/// Clears any showing footer notice as a side effect, exactly like `update`'s cases 3-5 do (the +/// confirm/help modals deliberately do not — that stays in their own arms, not here). +fn resolve_key( + app: &mut App, + keymap: &Keymap, + pending: &mut Vec, + key: KeyEvent, +) -> KeyOutcome { + if app.selection_anchor.is_some() && key.code == KeyCode::Esc && !app.outline_focused() { + app.clear_notice(); + app.cancel_selection(); + return KeyOutcome::Handled; + } + app.clear_notice(); + KeyOutcome::Action(map_key( + keymap, + pending, + key, + app.pane_height, + app.outline_focused(), + )) +} + /// Apply one [`AppEvent`] to `app`. Returns `true` when the loop should exit (q/Esc). Resize is a /// no-op — ratatui re-measures `body_area` every frame regardless. Tick drives /// [`App::on_tick`], the M4 index watcher's poll (see the module doc). @@ -226,7 +287,8 @@ fn apply_action(app: &mut App, action: Action) -> bool { /// 5. Otherwise the normal map applies, where Esc (like `q`) quits. /// /// A `Key` event clears any showing footer notice before applying its own action (cases 3-5); the -/// confirm and help modals (cases 1-2) deliberately do not. +/// confirm and help modals (cases 1-2) deliberately do not. Cases 3-5 are delegated to +/// [`resolve_key`], shared with [`update_batch`]. fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: AppEvent) -> bool { match event { AppEvent::Key(key) if app.pending_confirm.is_some() => { @@ -246,22 +308,10 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap } false } - AppEvent::Key(key) - if app.selection_anchor.is_some() - && key.code == KeyCode::Esc - && !app.outline_focused() => - { - app.clear_notice(); - app.cancel_selection(); - false - } - AppEvent::Key(key) => { - app.clear_notice(); - apply_action( - app, - map_key(keymap, pending, key, app.pane_height, app.outline_focused()), - ) - } + AppEvent::Key(key) => match resolve_key(app, keymap, pending, key) { + KeyOutcome::Handled => false, + KeyOutcome::Action(action) => apply_action(app, action), + }, AppEvent::Tick => { app.on_tick(); false @@ -270,6 +320,122 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap } } +/// One in-flight coalesced nav run tracked by [`update_batch`]: a same-sign burst of either +/// outline moves or diff-cursor moves, deferred until a context-changing event forces a flush. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RunKind { + OutlineMoveBy, + MoveCursorBy, +} + +/// Apply and clear `run`, if one is open. `outline_move_by`/`move_cursor_by` both clamp at their +/// ends, so one call with the summed delta lands exactly where the equivalent sequence of unit +/// calls would (see [`update_batch`]'s doc comment for why this only holds for same-sign runs). +fn flush_run(app: &mut App, run: &mut Option<(RunKind, i64)>) { + if let Some((kind, delta)) = run.take() { + match kind { + RunKind::OutlineMoveBy => app.outline_move_by(delta), + RunKind::MoveCursorBy => app.move_cursor_by(delta), + } + } +} + +/// Drain-and-coalesce entry point used by the event loop (`update` stays the single-event +/// primitive whose doc-comment cascade and tests are the spec — this delegates to it for +/// everything that isn't a coalescable nav key). +/// +/// Batches `events` (already drained by [`drain_pending`]) and merges same-sign runs of +/// `Action::OutlineMoveBy`/`Action::MoveCursorBy` into ONE deferred `App` call each, so +/// intermediate outline rows in a fast `j`/`k` burst are never opened (`render_body` only loads +/// the landing row, at draw time, via `App::ensure_loaded`). Returns `true` when the loop should +/// exit; remaining batched events after a quit are dropped. +/// +/// # Why coalescing same-sign runs is safe +/// +/// - Both `App::outline_move_by(delta)` and `App::move_cursor_by(delta)` clamp at the ends; for a +/// same-sign run, one call with the summed delta lands exactly where N unit calls land. Mixed +/// signs are NOT equivalent at a clamped boundary (`k` at row 0 then `j` = row 1, but summed +/// delta 0 = row 0 — a no-op) — hence a sign change always flushes the open run first. +/// - Applying a Move action never changes key-mapping context: it cannot toggle outline focus, +/// alter pane height (render sets it), open a modal, or change the keymap. So resolving key N+1 +/// before applying keys 1..N's deferred run is sound. Any action that COULD change context +/// (`ToggleOutline`, `ToggleHelp`, zoom, refresh, a modal, …) forces a flush before it is +/// applied, preserving strict ordering. +/// - `outline_move_by(sum)` only opens the LANDING row's file (the jump happens once, at the +/// final position) — this is precisely what skips the intermediate loads. +fn update_batch( + app: &mut App, + keymap: &Keymap, + pending: &mut Vec, + events: Vec, +) -> bool { + let mut run: Option<(RunKind, i64)> = None; + + for event in events { + match event { + // The coalescable path: no modal is up, and this isn't the selection-Esc-cancel + // guard (that guard is a context change — an "Esc cascade" — so it falls to the + // catch-all arm below, which flushes first and delegates the whole event to + // `update`). Notice-clearing still happens per key via `resolve_key`. + AppEvent::Key(key) + if app.pending_confirm.is_none() + && !app.help_visible + && !(app.selection_anchor.is_some() + && key.code == KeyCode::Esc + && !app.outline_focused()) => + { + match resolve_key(app, keymap, pending, key) { + KeyOutcome::Action(Action::OutlineMoveBy(delta)) => match &mut run { + Some((RunKind::OutlineMoveBy, acc)) if acc.signum() == delta.signum() => { + *acc += delta; + } + _ => { + flush_run(app, &mut run); + run = Some((RunKind::OutlineMoveBy, delta)); + } + }, + KeyOutcome::Action(Action::MoveCursorBy(delta)) => match &mut run { + Some((RunKind::MoveCursorBy, acc)) if acc.signum() == delta.signum() => { + *acc += delta; + } + _ => { + flush_run(app, &mut run); + run = Some((RunKind::MoveCursorBy, delta)); + } + }, + // Any other resolved action (Quit, ToggleHelp, chord-pending `Action::None`, + // …) can change context, so flush first, then apply it directly — `resolve_key` + // already did the notice-clear and keymap resolution `update` would have done + // for this key, so applying here (rather than re-delegating to `update`) + // avoids resolving the same key twice. + KeyOutcome::Action(action) => { + flush_run(app, &mut run); + if apply_action(app, action) { + return true; + } + } + // The selection-Esc guard already ran inline inside `resolve_key`, but the + // outer match guard above rules this arm's condition out before we ever + // reach it — kept for exhaustiveness. + KeyOutcome::Handled => flush_run(app, &mut run), + } + } + // Any other event — Tick, Resize, a modal-captured key, or the selection-Esc-cancel + // guard — flushes the open run first, then is handled with `update`'s existing, + // unmodified semantics. + _ => { + flush_run(app, &mut run); + if update(app, keymap, pending, event) { + return true; + } + } + } + } + + flush_run(app, &mut run); + false +} + /// Open the controlling terminal (`/dev/tty`) for writing, falling back to stdout when there is /// none (a pipe/CI with no tty). The TUI renders here rather than to stdout so it stays usable /// inside a shell command substitution: the `workon` wrapper function captures `git workon`'s @@ -336,7 +502,9 @@ fn event_loop( } if let Some(event) = next_event(Duration::from_millis(200))? { - quit = update(app, keymap, &mut pending, event); + let mut batch = vec![event]; + drain_pending(&mut batch)?; + quit = update_batch(app, keymap, &mut pending, batch); } } } @@ -1139,4 +1307,355 @@ mod tests { "the confirm arm must not have touched help_visible" ); } + + // ── CS2: coalesce buffered nav input ───────────────────────────────────── + + /// A single committed changeset with `n` distinct multi-line files ("f0.txt".."f{n-1}.txt"), + /// opened on file 0 — CS2's batching tests need several files so a coalesced outline jump has + /// intermediate rows to skip over, and several lines per file so a coalesced diff-cursor run + /// has room to move without immediately clamping. + fn many_files_app(fixture: &git_workon_fixture::fixture::Fixture, n: usize) -> App { + use git2::Repository; + use workon::{Changeset, ChangesetSpan}; + use workon_review::acquire::diff_changeset; + use workon_review::app::ChangesetView; + + let root = fixture + .commit("main") + .file("root.txt", "r\n") + .create("root") + .unwrap(); + let mut builder = fixture.commit("main"); + for i in 0..n { + builder = builder.file( + &format!("f{i}.txt"), + &format!("line-{i}-a\nline-{i}-b\nline-{i}-c\nline-{i}-d\nline-{i}-e\n"), + ); + } + let head = builder.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(), 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(); + app + } + + #[test] + fn batched_outline_jump_skips_intermediate_file_loads() { + use git_workon_fixture::prelude::*; + use workon_review::app::Role; + use workon_review::outline::OutlineMode; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = many_files_app(&fixture, 5); + app.set_outline_mode(OutlineMode::Flat); + app.toggle_outline(); // open + focus, cursor synced onto file 0's row (index 0 in Flat mode) + assert!(app.outline_focused()); + assert!( + app.role_view_ref(0, Role::Combined).is_some(), + "file 0 loaded by open_current" + ); + + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + // 4 outline-down keys: sequentially this would visit (and load) files 1, 2, 3, then land + // on 4 — coalescing must apply ONE outline_move_by(4), landing on file 4 directly. + let events = vec![ + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + ]; + + let quit = update_batch(&mut app, &km, &mut pending, events); + + assert!(!quit); + assert_eq!( + app.outline_cursor(), + 4, + "the outline cursor lands on the final row" + ); + assert_eq!(app.current, 4, "the diff jumps to the landing file only"); + for skipped in 1..4 { + assert!( + app.role_view_ref(skipped, Role::Combined).is_none(), + "file {skipped} must never have been visited, so its view must not be loaded" + ); + } + assert!( + app.role_view_ref(4, Role::Combined).is_some(), + "the landing file's view IS loaded" + ); + } + + #[test] + fn batched_outline_jump_matches_sequential_moves() { + use git_workon_fixture::prelude::*; + use workon_review::outline::OutlineMode; + + let fixture_batch = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app_batch = many_files_app(&fixture_batch, 5); + app_batch.set_outline_mode(OutlineMode::Flat); + app_batch.toggle_outline(); + + let fixture_seq = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app_seq = many_files_app(&fixture_seq, 5); + app_seq.set_outline_mode(OutlineMode::Flat); + app_seq.toggle_outline(); + + let km = Keymap::defaults(); + let mut pending_batch: Vec = Vec::new(); + let mut pending_seq: Vec = Vec::new(); + let events = vec![ + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + ]; + + update_batch(&mut app_batch, &km, &mut pending_batch, events.clone()); + for event in events { + update(&mut app_seq, &km, &mut pending_seq, event); + } + + assert_eq!(app_batch.outline_cursor(), app_seq.outline_cursor()); + assert_eq!(app_batch.current, app_seq.current); + } + + #[test] + fn mixed_direction_batch_matches_sequential_moves_including_at_a_clamp_boundary() { + use git_workon_fixture::prelude::*; + + // Mixed-sign run (j,j,j,k) starting away from any boundary. Each `App` gets its OWN + // fixture — `many_files_app` commits onto the fixture's `main`, so reusing one fixture + // across calls would have the second call's "head" commit re-add files the first call's + // "head" already committed, producing an empty diff for it. + let fixture_batch = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app_batch = many_files_app(&fixture_batch, 1); + let fixture_seq = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app_seq = many_files_app(&fixture_seq, 1); + let km = Keymap::defaults(); + let mut pending_batch: Vec = Vec::new(); + let mut pending_seq: Vec = Vec::new(); + let events = vec![ + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('k'))), + ]; + + update_batch(&mut app_batch, &km, &mut pending_batch, events.clone()); + for event in events { + update(&mut app_seq, &km, &mut pending_seq, event); + } + assert_eq!(app_batch.cursor, app_seq.cursor); + + // Clamp-boundary case: k then j starting at row 0 — a naive sum (0) would wrongly stay + // put; sequential unit calls land on row 1 (k clamps at 0, then j moves to 1). + let fixture_batch2 = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app_batch2 = many_files_app(&fixture_batch2, 1); + let fixture_seq2 = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app_seq2 = many_files_app(&fixture_seq2, 1); + let mut pending_batch2: Vec = Vec::new(); + let mut pending_seq2: Vec = Vec::new(); + let boundary_events = vec![ + AppEvent::Key(key(KeyCode::Char('k'))), + AppEvent::Key(key(KeyCode::Char('j'))), + ]; + + update_batch( + &mut app_batch2, + &km, + &mut pending_batch2, + boundary_events.clone(), + ); + for event in boundary_events { + update(&mut app_seq2, &km, &mut pending_seq2, event); + } + assert_eq!(app_batch2.cursor, app_seq2.cursor); + assert_eq!(app_batch2.cursor, 1, "k clamps at 0, then j moves to row 1"); + } + + #[test] + fn a_context_changing_key_mid_run_applies_moves_in_their_own_context() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = many_files_app(&fixture, 5); + // Default OutlineMode::Stack: row 0 is the header, row 1 is file 0 — so `o`'s + // sync-to-current lands the outline cursor on row 1, and a single outline `k` afterward + // lands on the header row (no file jump), leaving `app.cursor`/`app.current` observable. + assert!(!app.outline_open()); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + let events = vec![ + AppEvent::Key(key(KeyCode::Char('j'))), // MoveCursorBy(1), outline unfocused + AppEvent::Key(key(KeyCode::Char('j'))), // MoveCursorBy(1), outline unfocused + AppEvent::Key(key(KeyCode::Char('o'))), // ToggleOutline: open + focus + AppEvent::Key(key(KeyCode::Char('k'))), // OutlineMoveBy(-1), outline now focused + ]; + + let quit = update_batch(&mut app, &km, &mut pending, events); + + assert!(!quit); + assert_eq!( + app.cursor, 2, + "the two j's before `o` must apply as diff-cursor moves in the OLD context" + ); + assert!( + app.outline_open() && app.outline_focused(), + "`o` toggles the outline open and focused" + ); + assert_eq!( + app.outline_cursor(), + 0, + "the k after `o` must apply as an outline move in the NEW context, landing on the \ + header row" + ); + assert_eq!( + app.current, 0, + "landing on the header row must not jump the diff" + ); + } + + #[test] + fn a_pending_confirm_disables_coalescing_and_batch_matches_sequential_updates() { + use git_workon_fixture::prelude::*; + use workon_review::app::PendingOp; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app_batch = app_from_fixture(&fixture); + app_batch.open_current(); + let mut app_seq = app_from_fixture(&fixture); + app_seq.open_current(); + + app_batch.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); + app_seq.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); + + let km = Keymap::defaults(); + let mut pending_batch: Vec = Vec::new(); + let mut pending_seq: Vec = Vec::new(); + let cursor_before = app_batch.cursor; + let events = vec![ + AppEvent::Key(key(KeyCode::Char('j'))), // swallowed by the confirm modal + AppEvent::Key(key(KeyCode::Char('n'))), // cancels the confirm + ]; + + update_batch(&mut app_batch, &km, &mut pending_batch, events.clone()); + for event in events { + update(&mut app_seq, &km, &mut pending_seq, event); + } + + assert_eq!( + app_batch.cursor, cursor_before, + "a captured key inside the modal must not run its normal action" + ); + assert!(app_batch.pending_confirm.is_none(), "n cancels the confirm"); + assert_eq!(app_batch.cursor, app_seq.cursor); + assert_eq!( + app_batch.pending_confirm.is_none(), + app_seq.pending_confirm.is_none() + ); + } + + #[test] + fn quit_mid_batch_drops_the_remaining_events_and_returns_true() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = many_files_app(&fixture, 1); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + let cursor_before = app.cursor; + let events = vec![ + AppEvent::Key(key(KeyCode::Char('j'))), // applies: cursor_before + 1 + AppEvent::Key(key(KeyCode::Char('q'))), // quits + AppEvent::Key(key(KeyCode::Char('j'))), // dropped: must never apply + ]; + + let quit = update_batch(&mut app, &km, &mut pending, events); + + assert!(quit, "q mid-batch must report quit"); + assert_eq!( + app.cursor, + cursor_before + 1, + "only the j before q must have applied" + ); + } + + #[test] + fn a_chord_split_across_two_batches_still_fires() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = many_files_app(&fixture, 3); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + assert_eq!(app.current, 0); + + // First batch: only the chord's first key arrives — held in `pending` across the drain + // boundary, exactly like a real terminal delivering the two keys in separate polls. + let quit1 = update_batch( + &mut app, + &km, + &mut pending, + vec![AppEvent::Key(key(KeyCode::Char(']')))], + ); + assert!(!quit1); + assert_eq!(pending, vec![KeyPress::from_event(key(KeyCode::Char(']')))]); + + // Second batch: the chord's second key completes it via the SAME `pending` buffer. + let quit2 = update_batch( + &mut app, + &km, + &mut pending, + vec![AppEvent::Key(key(KeyCode::Char('f')))], + ); + assert!(!quit2); + assert!(pending.is_empty()); + assert_eq!(app.current, 1, "]f must have fired NextFile"); + } } From e1acb772785dab09d9f84e35c703fd58d3e6443c Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 15:25:20 -0400 Subject: [PATCH 073/203] fix(review): land diff on last crossed file for header landings --- git-workon-review/src/app.rs | 86 ++++++++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 7430cc2..970f019 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1649,12 +1649,22 @@ impl App { /// Move the outline's own cursor by `delta` rows (`j`/`k` while the outline has focus), /// clamped into the current row list. Landing on a FILE row jumps the diff there - /// immediately (outline -> diff, per the locked design); landing on a HEADER row does NOT - /// jump — only [`Self::outline_confirm`] (`Enter`) jumps from a header, since a header's - /// "first file" isn't necessarily where a `j`/`k` scan through the stack should keep - /// stopping the diff. This calls [`Self::switch_changeset`] directly (not `next_file`/ - /// `goto_changeset`), so it does NOT re-trigger [`Self::sync_outline_to_current`] — see that - /// method's doc comment for why only the DIFF-initiated entry points do. + /// immediately (outline -> diff, per the locked design); a HEADER/DIR row itself never + /// causes a jump — only [`Self::outline_confirm`] (`Enter`) jumps from a header, since a + /// header's "first file" isn't necessarily where a `j`/`k` scan through the stack should + /// keep stopping the diff. This calls [`Self::switch_changeset`] directly (not + /// `next_file`/`goto_changeset`), so it does NOT re-trigger + /// [`Self::sync_outline_to_current`] — see that method's doc comment for why only the + /// DIFF-initiated entry points do. + /// + /// A multi-row `delta` is a coalesced burst of unit presses (the event loop merges + /// same-sign `j`/`k` runs — see `tui.rs`'s `update_batch`), so it must be + /// indistinguishable from the unit presses it stands for: N unit moves jump the diff at + /// every FILE row they cross, leaving it on the LAST one when the run stops on a + /// header/dir row. So a non-File landing scans back toward (but excluding) the starting + /// row for the last file crossed and jumps there. For a unit move that range is empty, + /// preserving the single-press rule above: bare `j`/`k` onto a header neither jumps nor + /// resets the diff. pub fn outline_move_by(&mut self, delta: i64) { let items = self.outline_items(); if items.is_empty() { @@ -1670,6 +1680,19 @@ impl App { } = &items[new_idx] { self.switch_changeset(*cs_idx, *file_idx); + } else if new_idx as i64 != cur { + let step = if delta > 0 { -1 } else { 1 }; + let mut idx = new_idx as i64 + step; + while idx != cur && (0..=max).contains(&idx) { + if let OutlineItem::File { + cs_idx, file_idx, .. + } = &items[idx as usize] + { + self.switch_changeset(*cs_idx, *file_idx); + break; + } + idx += step; + } } } @@ -5391,17 +5414,52 @@ mod tests { fn outline_move_by_on_a_header_row_does_not_jump_the_diff() { let mut app = two_committed_changesets_two_and_one_files(); app.outline.mode = OutlineMode::Stack; - app.outline.cursor = 0; // cs-a's header row - let cs_before = app.current_cs(); - let file_before = app.current; - // Header rows sit at indices 0 (cs-a) and 3 (cs-b) in Stack mode (header, a1, a2, - // header). Move onto the cs-b header without landing on a file row in between. - app.outline_move_by(3); + // header, b1). Park the diff on a2, cursor on its row. + app.outline.cursor = 2; + app.switch_changeset(0, 1); + app.cursor += 1; // nudge off the open position so a hidden re-open would be visible + let cursor_before = app.cursor; + + // A UNIT move onto the header: the header itself never jumps — and must not reset the + // diff's cursor either (a re-`switch_changeset` to the same file would). + app.outline_move_by(1); assert_eq!( (app.current_cs(), app.current), - (cs_before, file_before), - "landing the outline cursor on a header row must not move the diff" + (0, 1), + "a bare j onto a header row must not move the diff" + ); + assert_eq!(app.cursor, cursor_before, "...nor reset the diff cursor"); + } + + #[test] + fn coalesced_outline_burst_onto_a_header_matches_sequential_unit_moves() { + // A multi-row delta is CS2's coalesced stand-in for N unit presses, so the two must be + // indistinguishable — including which file the diff follows when the burst stops on a + // header row (the LAST file crossed, exactly where unit presses leave it). + let mut coalesced = two_committed_changesets_two_and_one_files(); + coalesced.outline.mode = OutlineMode::Stack; + coalesced.outline.cursor = 0; + coalesced.outline_move_by(3); // header -> a1 -> a2 -> cs-b header + + let mut sequential = two_committed_changesets_two_and_one_files(); + sequential.outline.mode = OutlineMode::Stack; + sequential.outline.cursor = 0; + for _ in 0..3 { + sequential.outline_move_by(1); + } + + assert_eq!(coalesced.outline.cursor, sequential.outline.cursor); + assert_eq!( + (coalesced.current_cs(), coalesced.current), + (sequential.current_cs(), sequential.current), + "a summed burst stopping on a header must leave the diff on the last file \ + crossed, like the unit presses it coalesces" + ); + assert_eq!( + (coalesced.current_cs(), coalesced.current), + (0, 1), + "...which is a2 here" ); } From 84269337e7c65ac1e50da9a55297d18b44b38ba8 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 15:27:03 -0400 Subject: [PATCH 074/203] refactor(review): collapse duplicated nav-coalescing arms --- git-workon-review/src/tui.rs | 49 ++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index e0e532c..ceb3599 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -320,6 +320,17 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap } } +/// The run kind and delta for an action [`update_batch`] can coalesce, or `None` for every +/// other action. The single source of truth for WHICH actions coalesce — `update_batch`'s +/// accumulate arm matches through this so the rule can't drift per action kind. +fn coalescable(action: Action) -> Option<(RunKind, i64)> { + match action { + Action::OutlineMoveBy(delta) => Some((RunKind::OutlineMoveBy, delta)), + Action::MoveCursorBy(delta) => Some((RunKind::MoveCursorBy, delta)), + _ => None, + } +} + /// One in-flight coalesced nav run tracked by [`update_batch`]: a same-sign burst of either /// outline moves or diff-cursor moves, deferred until a context-changing event forces a flush. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -361,8 +372,9 @@ fn flush_run(app: &mut App, run: &mut Option<(RunKind, i64)>) { /// before applying keys 1..N's deferred run is sound. Any action that COULD change context /// (`ToggleOutline`, `ToggleHelp`, zoom, refresh, a modal, …) forces a flush before it is /// applied, preserving strict ordering. -/// - `outline_move_by(sum)` only opens the LANDING row's file (the jump happens once, at the -/// final position) — this is precisely what skips the intermediate loads. +/// - `outline_move_by(sum)` opens at most ONE file — the landing row's, or for a header/dir +/// landing the last file the burst crossed (see its doc comment) — rather than one per row +/// crossed. That single jump is precisely what skips the intermediate loads. fn update_batch( app: &mut App, keymap: &Keymap, @@ -385,24 +397,23 @@ fn update_batch( && !app.outline_focused()) => { match resolve_key(app, keymap, pending, key) { - KeyOutcome::Action(Action::OutlineMoveBy(delta)) => match &mut run { - Some((RunKind::OutlineMoveBy, acc)) if acc.signum() == delta.signum() => { - *acc += delta; + // A coalescable nav action extends the open run when it matches in kind + // and sign, else flushes and starts a fresh run — one arm for both kinds + // so the coalescing rule can't drift between them. + KeyOutcome::Action(action) if coalescable(action).is_some() => { + let Some((kind, delta)) = coalescable(action) else { + continue; // unreachable: the guard just matched + }; + match &mut run { + Some((k, acc)) if *k == kind && acc.signum() == delta.signum() => { + *acc += delta; + } + _ => { + flush_run(app, &mut run); + run = Some((kind, delta)); + } } - _ => { - flush_run(app, &mut run); - run = Some((RunKind::OutlineMoveBy, delta)); - } - }, - KeyOutcome::Action(Action::MoveCursorBy(delta)) => match &mut run { - Some((RunKind::MoveCursorBy, acc)) if acc.signum() == delta.signum() => { - *acc += delta; - } - _ => { - flush_run(app, &mut run); - run = Some((RunKind::MoveCursorBy, delta)); - } - }, + } // Any other resolved action (Quit, ToggleHelp, chord-pending `Action::None`, // …) can change context, so flush first, then apply it directly — `resolve_key` // already did the notice-clear and keymap resolution `update` would have done From ec9b68993c98f6fa7146d5ea5ec66ace2118390a Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 12:24:48 -0400 Subject: [PATCH 075/203] perf(review): parallelize changeset diff acquisition --- git-workon-review/src/acquire.rs | 60 ++++++++++++++++++ git-workon-review/src/app.rs | 20 +++--- git-workon-review/src/main.rs | 13 ++-- git-workon-review/tests/diff_model.rs | 89 +++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 15 deletions(-) diff --git a/git-workon-review/src/acquire.rs b/git-workon-review/src/acquire.rs index 1e12042..d752919 100644 --- a/git-workon-review/src/acquire.rs +++ b/git-workon-review/src/acquire.rs @@ -140,6 +140,66 @@ pub fn diff_changeset(repo: &Repository, cs: &Changeset) -> Result Result, DiffError> { + let workers = std::thread::available_parallelism() + .map(std::num::NonZeroUsize::get) + .unwrap_or(1) + .min(changesets.len()); + if workers <= 1 { + return changesets + .iter() + .map(|cs| diff_changeset(repo, cs)) + .collect(); + } + + // Workers re-open at the workdir so the Uncommitted span's index/worktree diffs resolve + // against the same working tree as `repo`; the gitdir is the fallback for a bare repo + // (where only committed spans can occur). + let open_at = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf(); + + let chunk = changesets.len().div_ceil(workers); + let mut results: Vec>> = Vec::new(); + results.resize_with(changesets.len(), || None); + + std::thread::scope(|scope| { + for (cs_chunk, out_chunk) in changesets.chunks(chunk).zip(results.chunks_mut(chunk)) { + let open_at = &open_at; + scope.spawn(move || { + let repo = match Repository::open(open_at) { + Ok(repo) => repo, + Err(err) => { + // Every changeset in this chunk is undiffable without a handle; the + // first slot's error is the one input-order selection below reports. + out_chunk[0] = Some(Err(err.into())); + return; + } + }; + for (cs, out) in cs_chunk.iter().zip(out_chunk.iter_mut()) { + *out = Some(diff_changeset(&repo, cs)); + } + }); + } + }); + + results + .into_iter() + .map(|slot| slot.expect("every chunk fills its slots or errors its first slot")) + .collect() +} + /// Resolve the changeset stack the review App opens on for the worktree whose `HEAD` is /// `head_branch` (locked design decision M5-fork-7, "auto-detect"): the full Graphite stack /// when one is active, or a single synthetic [`Changeset`] spanning the uncommitted worktree diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 970f019..1bd234b 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1068,16 +1068,18 @@ impl App { } }; - let mut views = Vec::with_capacity(changesets.len()); - for cs in changesets { - match crate::acquire::diff_changeset(&self.repo, &cs) { - Ok(diff) => views.push(ChangesetView::from_changeset_diff(cs, diff)), - Err(err) => { - self.notify(format!("refresh failed: {err}"), Severity::Error); - return; - } + let diffs = match crate::acquire::diff_changesets(&self.repo, &changesets) { + Ok(diffs) => diffs, + Err(err) => { + self.notify(format!("refresh failed: {err}"), Severity::Error); + return; } - } + }; + let views: Vec = changesets + .into_iter() + .zip(diffs) + .map(|(cs, diff)| ChangesetView::from_changeset_diff(cs, diff)) + .collect(); // `resolve_changesets` always returns at least one changeset (a lone Uncommitted entry // when no stack is active), but stay defensive rather than index an empty `Vec` below. if views.is_empty() { diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index c69380b..36c11c0 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -5,7 +5,7 @@ use clap_complete::engine::ArgValueCompleter; use clap_complete::env::CompleteEnv; use git2::Repository; use miette::{IntoDiagnostic, Result}; -use workon_review::acquire::{diff_changeset, resolve_changesets}; +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; @@ -52,11 +52,12 @@ fn main() -> Result<()> { Some(source) => resolve_source(&repo, &branch, source.clone()).into_diagnostic()?, }; - let mut views = Vec::with_capacity(changesets.len()); - for cs in changesets { - let diff = diff_changeset(&repo, &cs).into_diagnostic()?; - views.push(ChangesetView::from_changeset_diff(cs, diff)); - } + let diffs = diff_changesets(&repo, &changesets).into_diagnostic()?; + let views: Vec = changesets + .into_iter() + .zip(diffs) + .map(|(cs, diff)| ChangesetView::from_changeset_diff(cs, diff)) + .collect(); // A resolved source can legitimately name zero changesets — `stack` on a branch that's // caught up with its upstream and has a clean tree hits `assemble_git`'s empty-vec arm diff --git a/git-workon-review/tests/diff_model.rs b/git-workon-review/tests/diff_model.rs index 4da7a5f..25c9bcf 100644 --- a/git-workon-review/tests/diff_model.rs +++ b/git-workon-review/tests/diff_model.rs @@ -469,6 +469,95 @@ fn diff_changeset_over_real_graphite_stack() -> Result<(), Box Result<(), Box> { + // A two-branch Graphite stack plus the uncommitted layer: exercises every span kind the + // parallel fan-out stripes across workers (Committed × 2, Uncommitted) in one call. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .branch_metadata("b", "a") + .unstaged_file("tracked.txt", "line1\nline2\n", "line1\nCHANGED\n") + .build()?; + let repo = fixture.repo()?; + + // Advance "a" and "b" independently so each committed changeset has its own diff. + let main_tip = repo + .find_branch("main", BranchType::Local)? + .get() + .target() + .unwrap(); + let a_head = commit_onto(repo, &repo.find_commit(main_tip)?, "a.txt", "from a\n"); + fixture.update_branch("a", a_head)?; + let b_head = commit_onto(repo, &repo.find_commit(a_head)?, "b.txt", "from b\n"); + fixture.update_branch("b", b_head)?; + + let changesets = + assemble_changesets(repo, "b", StackModel::Graphite, UncommittedLayer::Include)?; + assert!( + changesets.len() >= 3, + "expected two committed changesets plus the uncommitted layer" + ); + + let parallel = workon_review::acquire::diff_changesets(repo, &changesets)?; + + assert_eq!(parallel.len(), changesets.len()); + for (cs, got) in changesets.iter().zip(¶llel) { + let sequential = diff_changeset(repo, cs)?; + assert_eq!( + got, &sequential, + "parallel diff for '{}' must match the sequential one, in input order", + cs.name + ); + } + + Ok(()) +} + +#[test] +fn diff_changesets_surfaces_the_first_failing_changeset_by_input_order( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build()?; + let repo = fixture.repo()?; + let head = repo.head()?.target().unwrap(); + + let good = Changeset { + name: "good".to_string(), + span: ChangesetSpan::CommittedRoot { head }, + title: None, + current: false, + needs_restack: false, + }; + let garbage = Oid::from_str("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")?; + let bad = |name: &str| Changeset { + name: name.to_string(), + span: ChangesetSpan::Committed { + base: garbage, + head, + }, + title: None, + current: true, + needs_restack: false, + }; + let changesets = vec![good, bad("bad-first"), bad("bad-second")]; + + let err = workon_review::acquire::diff_changesets(repo, &changesets) + .expect_err("a garbage base Oid must fail the whole acquisition"); + match err { + DiffError::ChangesetDiffFailed { name, .. } => assert_eq!( + name, "bad-first", + "the FIRST failing changeset by input order is the one reported" + ), + other => panic!("expected ChangesetDiffFailed, got {other:?}"), + } + + Ok(()) +} + #[test] fn diff_changeset_with_bad_base_oid_fails_never_empty() -> Result<(), Box> { let fixture = FixtureBuilder::new().build()?; From 6dd3e532fe8bdc26be7ca6aa5da01f5959ef1c44 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 13:25:43 -0400 Subject: [PATCH 076/203] perf(review): defer file loads to an input-idle window --- git-workon-review/src/app.rs | 140 +++++++++++++++++++++ git-workon-review/src/main.rs | 4 + git-workon-review/src/render.rs | 97 ++++++++++++++- git-workon-review/src/tui.rs | 211 +++++++++++++++++++++++++++++++- 4 files changed, 447 insertions(+), 5 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 1bd234b..faef754 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -756,6 +756,17 @@ pub struct App { /// setter (rather than a constructor parameter) so `App::from_changesets`'s signature, and /// every existing test building through it, stays untouched. review_source: Option, + /// CS4's idle-deferred load switch. `false` (the default) keeps every pre-CS4 + /// `open_current`/render-path behavior byte-identical, so the ~80 existing tests asserting + /// eager loads keep passing unchanged. `main.rs` turns this on via [`Self::set_defer_loads`] + /// right after construction; the event loop is what actually defers (see `tui.rs`'s + /// `OPEN_DEBOUNCE`). + defer_loads: bool, + /// Set when [`Self::open_current`] deferred its load (only possible while + /// [`Self::defer_loads`] is on) — the render path shows a placeholder instead of loading + /// while this is `true`, and the event loop calls [`Self::complete_pending_open`] once input + /// has been quiet for `OPEN_DEBOUNCE`. Read via [`Self::open_pending`]. + open_pending: bool, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -891,6 +902,8 @@ impl App { outline, help_visible: false, review_source: None, + defer_loads: false, + open_pending: 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 @@ -1391,10 +1404,60 @@ impl App { self.derive_scroll(); } + /// Turn CS4's idle-deferred load mode on/off. `main.rs` calls this with `true` right after + /// [`Self::from_changesets`], before the first [`Self::open_current`] — see the field's doc + /// comment. Exposed as a setter (rather than folded into construction) so every existing test + /// building through `from_changesets`/`App::new` keeps today's eager behavior untouched. + pub fn set_defer_loads(&mut self, on: bool) { + self.defer_loads = on; + } + + /// Whether CS4's idle-deferred load mode is on — see [`Self::set_defer_loads`]. + pub fn defer_loads(&self) -> bool { + self.defer_loads + } + + /// Whether [`Self::open_current`] deferred its load and it hasn't been completed yet — the + /// render path (in defer mode) and the event loop both read this: render to decide whether to + /// show the placeholder, the event loop to decide whether to shorten its poll timeout and to + /// call [`Self::complete_pending_open`] on the next idle tick. + pub fn open_pending(&self) -> bool { + self.open_pending + } + /// Load the current file's needed views and reset both panes to their first hunks. + /// + /// In [`Self::defer_loads`] mode this does NOT load: it marks the open pending and resets the + /// panes anyway (the cursor falls back to row 0 for the still-unloaded view, via + /// [`Self::role_first_hunk`]'s `unwrap_or(0)` — harmless, since the body renders a placeholder + /// until [`Self::complete_pending_open`] runs). Outside defer mode this is exactly today's + /// eager behavior. pub fn open_current(&mut self) { + if self.defer_loads { + self.open_pending = true; + self.reset_panes(); + return; + } + self.ensure_loaded(self.current); + self.reset_panes(); + } + + /// Complete a deferred open, if one is pending: load the current file's needed views, then + /// reset both panes again so the cursor now derives from the REAL first-hunk row (rather than + /// the `0` fallback [`Self::open_current`] left it at). A no-op when nothing is pending — + /// idempotent, so the event loop can call this liberally (e.g. on every idle tick while + /// pending) without worrying about double-loading. + /// + /// Invariant this pins (the equivalence the tests assert): after this returns, `App` state is + /// byte-identical to what an eager [`Self::open_current`] would have produced for the same + /// current file. + pub fn complete_pending_open(&mut self) { + if !self.open_pending { + return; + } self.ensure_loaded(self.current); self.reset_panes(); + self.open_pending = false; } /// Cycle the requested zoom `Split → Combined → Unstaged → Staged → Split` (`z`). The new zoom @@ -2822,6 +2885,83 @@ mod tests { assert!(app.current_view_ref().is_none()); } + // ── CS4: idle-deferred loads ────────────────────────────────────────────── + + /// A twin pair: one `App` with `defer_loads` off (the eager baseline), one with it on. Both + /// built from independent copies of the SAME fixture so their diffs (and hunks) line up. + fn defer_and_eager_twins(fixture: &git_workon_fixture::fixture::Fixture) -> (App, App) { + let mut eager = app_from_fixture(fixture); + eager.open_current(); + + let mut deferred = app_from_fixture(fixture); + deferred.set_defer_loads(true); + deferred.open_current(); + + (deferred, eager) + } + + #[test] + fn open_current_defers_load_and_complete_matches_eager_open() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "tracked.txt", + "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nold\nl10\nl11\nl12\n", + "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nnew\nl10\nl11\nl12\n", + ) + .build() + .unwrap(); + + let (mut deferred, eager) = defer_and_eager_twins(&fixture); + + // `open_current` under defer mode loads NOTHING and marks the open pending. + assert!( + deferred.current_view_ref().is_none(), + "deferred open_current must not have loaded the current view" + ); + assert!(deferred.open_pending(), "the open must be marked pending"); + + deferred.complete_pending_open(); + + assert!( + !deferred.open_pending(), + "complete_pending_open must clear the pending flag" + ); + assert_eq!( + deferred.cursor, eager.cursor, + "cursor must land on the same (first-hunk) row an eager open would have" + ); + assert_eq!(deferred.scroll, eager.scroll); + let deferred_view = deferred.current_view_ref().expect("view now loaded"); + let eager_view = eager.current_view_ref().expect("eager view loaded"); + assert_eq!(deferred_view.old_text(), eager_view.old_text()); + assert_eq!(deferred_view.new_text(), eager_view.new_text()); + assert_eq!(deferred_view.display.len(), eager_view.display.len()); + } + + #[test] + fn complete_pending_open_is_a_no_op_when_nothing_pending() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("tracked.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.set_defer_loads(true); + app.open_current(); + app.complete_pending_open(); + assert!(!app.open_pending()); + + let cursor_before = app.cursor; + let scroll_before = app.scroll; + // Calling again with nothing pending must not touch cursor/scroll or reload anything. + app.complete_pending_open(); + assert!(!app.open_pending()); + assert_eq!(app.cursor, cursor_before); + assert_eq!(app.scroll, scroll_before); + } + // Hunk-nav helpers below operate purely over `DisplayRow` vectors — no fixture repo needed. fn ctx_row(n: usize) -> DisplayRow { diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 36c11c0..c000d9f 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -109,6 +109,10 @@ fn main() -> Result<()> { if let Some(source) = source { app.set_review_source(source); } + // CS4: defer file loads to the event loop's input-idle window rather than blocking here (or + // on any later selection change) — `app.open_current()` below marks the initial open pending + // instead of loading eagerly; see `tui::run`'s doc comment for the resulting startup contract. + app.set_defer_loads(true); // Apply CS7's view-config settings BEFORE `open_current`: `App::apply_view_config`'s setters // only set the raw layout/zoom/mode/width fields, and `open_current` is what derives diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index b062b50..c85bd10 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -726,6 +726,45 @@ fn render_gap_row( buf.set_line(area.x, y, &line, area.width); } +/// Whether file `idx` needs CS4's deferred-load placeholder instead of its real diff: either the +/// current open is still pending (set by [`App::open_current`] in defer mode — see its doc +/// comment), or it isn't pending but the view(s) its effective zoom needs haven't been loaded yet +/// (e.g. a force-completed OTHER file's load left this one's cache untouched). Under +/// [`EffectiveZoom::Split`] the placeholder shows only when NEITHER pane is loaded: a role with +/// no change for the file stays legitimately `None` forever (see `ensure_role_loaded`), so +/// gating on both panes would placeholder a one-role file for good. Once +/// [`App::complete_pending_open`] runs, every loadable pane is loaded, and a role-less pane +/// renders empty exactly as it did pre-CS4. +fn needs_deferred_placeholder(app: &App, idx: usize) -> bool { + if app.open_pending() { + return true; + } + match app.effective_zoom_for(idx) { + EffectiveZoom::Single(role) => app.role_view_ref(idx, role).is_none(), + EffectiveZoom::Split => { + app.role_view_ref(idx, Role::Unstaged).is_none() + && app.role_view_ref(idx, Role::Staged).is_none() + } + } +} + +/// Render CS4's deferred-load placeholder: a dim one-line paragraph naming the file, matching the +/// existing binary-file placeholder's style (see `render_body`'s binary arm) so the two read as +/// the same kind of "nothing to show yet" message. +fn render_loading_placeholder( + frame: &mut Frame, + app: &App, + area: Rect, + idx: usize, + theme: &Palette, +) { + let msg = format!("{} — loading…", app.files()[idx].path); + frame.render_widget( + Paragraph::new(msg).style(Style::default().fg(theme.dim)), + area, + ); +} + fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { if app.files().is_empty() { frame.render_widget(Paragraph::new("(no changes)"), area); @@ -742,7 +781,17 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { return; } - app.ensure_loaded(idx); + // CS4: in defer mode, selection changes never load — the diff body shows a placeholder until + // the event loop's idle window (`tui.rs`'s `OPEN_DEBOUNCE`) runs `complete_pending_open` + // between frames. Do NOT call `ensure_loaded` from this path in defer mode; outside defer mode + // (the default), behavior is unchanged. + if app.defer_loads() && needs_deferred_placeholder(app, idx) { + render_loading_placeholder(frame, app, area, idx, theme); + return; + } + if !app.defer_loads() { + app.ensure_loaded(idx); + } // The gate re-evaluates the effective zoom for the current file every frame (no caching — // ratatui relayout is free, per locked decision #3). @@ -1352,6 +1401,52 @@ mod tests { ); } + // ── CS4: idle-deferred loads ────────────────────────────────────────────── + + #[test] + fn defer_mode_shows_placeholder_and_does_not_load() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("tracked.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.set_defer_loads(true); + app.open_current(); // marks pending; does not load + + let buf = render_once(&mut app, 60, 10); + let content = buf_lines(&buf); + assert!( + content + .iter() + .any(|line| line.contains("tracked.txt") && line.contains("loading")), + "expected the CS4 loading placeholder, got:\n{}", + content.join("\n") + ); + assert!( + app.current_view_ref().is_none(), + "rendering in defer mode must not have triggered a load" + ); + } + + #[test] + fn non_defer_mode_still_loads_from_the_render_path() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("tracked.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + // `defer_loads` defaults off — render must still load eagerly, exactly like before CS4. + let _ = render_once(&mut app, 60, 10); + assert!( + app.current_view_ref().is_some(), + "non-defer mode must still load from the render path" + ); + } + #[test] fn deleted_file_renders_one_sided() { let fixture = FixtureBuilder::new() diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index ceb3599..2497284 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -186,8 +186,45 @@ fn map_key( } } +/// Whether `action`'s effect READS the current [`App::current_view`]/cursor-space state +/// (cursor-space movement, staging, selection) rather than only changing WHICH file/changeset is +/// current. An action in the first group must force-complete any pending deferred open first (see +/// [`apply_action`]'s chokepoint) so it observes the same loaded view an eager `open_current` would +/// have produced — e.g. `j` then immediately `s` must stage the same hunk eager code would have. +/// +/// Exempt (returns `false`): every action that ends in its own fresh `open_current` (`NextFile`, +/// `PrevFile`, `NextChangeset`, `PrevChangeset`, `CycleZoom`, and the outline nav/confirm actions), +/// since those simply set a NEW pending open rather than needing the current one force-completed; +/// plus pure UI toggles/no-ops (`Refresh` rebuilds all views itself; `ToggleHelp`/`Quit`/`None` +/// touch no view state at all). +fn action_needs_loaded_view(action: Action) -> bool { + matches!( + action, + Action::MoveCursorBy(_) + | Action::ScrollTop + | Action::ScrollBottom + | Action::NextHunk + | Action::PrevHunk + | Action::StageHunk + | Action::StageFile + | Action::DiscardHunk + | Action::DiscardFile + | Action::StartSelection + | Action::ToggleSplitFocus + ) +} + /// Apply an [`Action`] to `app`. Returns `true` when the loop should exit. +/// +/// Chokepoint (CS4): before doing anything else, force-complete a pending deferred open for every +/// action [`action_needs_loaded_view`] flags — see that function's doc comment for the principle +/// and the exemption list. [`App::complete_pending_open`] is a no-op when nothing is pending, so +/// this costs nothing outside defer mode (where `open_pending` is never set) or when the debounce +/// window already completed the open on its own. fn apply_action(app: &mut App, action: Action) -> bool { + if action_needs_loaded_view(action) { + app.complete_pending_open(); + } match action { Action::Quit => return true, Action::ToggleHelp => app.toggle_help(), @@ -342,11 +379,21 @@ enum RunKind { /// Apply and clear `run`, if one is open. `outline_move_by`/`move_cursor_by` both clamp at their /// ends, so one call with the summed delta lands exactly where the equivalent sequence of unit /// calls would (see [`update_batch`]'s doc comment for why this only holds for same-sign runs). +/// +/// `MoveCursorBy` reads cursor-space state exactly like [`apply_action`]'s `Action::MoveCursorBy` +/// arm does, and this is the OTHER path (besides `apply_action`) that can run it — CS2's +/// coalescing calls `App::move_cursor_by` directly rather than routing the flush through +/// `apply_action`, so the same force-completion has to happen here too (see the plan's chokepoint +/// note: whichever path applies `MoveCursorBy` must complete first). `OutlineMoveBy` needs no such +/// call: it ends in its own fresh `open_current`, exactly like `apply_action`'s exemption list. fn flush_run(app: &mut App, run: &mut Option<(RunKind, i64)>) { if let Some((kind, delta)) = run.take() { match kind { RunKind::OutlineMoveBy => app.outline_move_by(delta), - RunKind::MoveCursorBy => app.move_cursor_by(delta), + RunKind::MoveCursorBy => { + app.complete_pending_open(); + app.move_cursor_by(delta); + } } } } @@ -477,8 +524,12 @@ fn install_panic_hook() { })); } -/// Run the review TUI's terminal lifecycle and main loop against `app`. Callers must have -/// already loaded the initial file (`app.open_current()`) before calling this. +/// Run the review TUI's terminal lifecycle and main loop against `app`. Callers must have already +/// called `app.open_current()` before calling this — under CS4's deferred-load mode +/// (`app.set_defer_loads(true)`, `main.rs`'s default) that call marks the open PENDING rather than +/// loading eagerly, so the first frame shows CS4's placeholder for one `OPEN_DEBOUNCE` window +/// instead of blocking startup on the initial file's load; a caller that never turned defer mode +/// on gets today's eager behavior unchanged. pub fn run(app: &mut App, keymap: &Keymap, theme: &Palette) -> io::Result<()> { install_panic_hook(); enable_raw_mode()?; @@ -496,6 +547,13 @@ pub fn run(app: &mut App, keymap: &Keymap, theme: &Palette) -> io::Result<()> { result } +/// CS4's input-idle window: how long the loop waits with no new input before running a pending +/// deferred file open. Long enough that held-key autorepeat (~30-90ms between events on most +/// terminals) usually keeps re-arming the debounce and deferring the load past the whole burst; +/// short enough that releasing the key feels instant rather than laggy. Tunable if either edge +/// proves wrong in practice — there is nothing else load-bearing about this exact number. +const OPEN_DEBOUNCE: Duration = Duration::from_millis(80); + fn event_loop( terminal: &mut Terminal>, app: &mut App, @@ -512,7 +570,22 @@ fn event_loop( return Ok(()); } - if let Some(event) = next_event(Duration::from_millis(200))? { + // While an open is pending, poll on the short debounce window instead of the regular + // 200ms redraw beat, so the deferred load runs promptly once input goes quiet — a plain + // timeout (no new terminal event) is what "quiet" means here. This borrows the same + // `Tick` beat the M4 index watcher already polls on (see the module doc); the watcher + // occasionally running ~120ms early during a debounce window is harmless (its own doc + // comment already tolerates an "unseen" signature settling one tick late). + let timeout = if app.open_pending() { + OPEN_DEBOUNCE + } else { + Duration::from_millis(200) + }; + + if let Some(event) = next_event(timeout)? { + if matches!(event, AppEvent::Tick) && app.open_pending() { + app.complete_pending_open(); + } let mut batch = vec![event]; drain_pending(&mut batch)?; quit = update_batch(app, keymap, &mut pending, batch); @@ -1669,4 +1742,134 @@ mod tests { assert!(pending.is_empty()); assert_eq!(app.current, 1, "]f must have fired NextFile"); } + + // ── CS4: idle-deferred loads ────────────────────────────────────────────── + + #[test] + fn deferred_outline_burst_loads_nothing_until_completed() { + use git_workon_fixture::prelude::*; + use workon_review::app::Role; + use workon_review::outline::OutlineMode; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = many_files_app(&fixture, 5); + // `many_files_app` opens eagerly (defer mode isn't on yet) — file 0 is loaded before we + // flip the switch, exactly like a real session's startup open would be under CS4 (see + // `main.rs`, which turns defer mode on before its own initial `open_current`). + app.set_defer_loads(true); + app.set_outline_mode(OutlineMode::Flat); + app.toggle_outline(); // open + focus, cursor synced onto file 0's row + + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + let events = vec![ + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + ]; + + let quit = update_batch(&mut app, &km, &mut pending, events); + + assert!(!quit); + assert_eq!(app.current, 4, "the outline jump still lands on file 4"); + assert!( + app.open_pending(), + "landing on file 4 in defer mode must mark the open pending, not load it" + ); + for f in 1..=4 { + assert!( + app.role_view_ref(f, Role::Combined).is_none(), + "file {f} must not be loaded — not even the landing file, until completed" + ); + } + + app.complete_pending_open(); + + assert!(!app.open_pending()); + assert!( + app.role_view_ref(4, Role::Combined).is_some(), + "completing the pending open loads only the landing file" + ); + } + + #[test] + fn force_completion_before_move_lets_stage_hit_the_eager_hunk() { + use git_workon_fixture::prelude::*; + + // Twin fixtures with identical content: one driven through defer mode (open_current + // defers, `j` must force-complete before moving, then `s` stages), the other through + // today's eager path — both must end up staging the exact same hunk. + let fixture_deferred = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + let fixture_eager = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app_deferred = app_from_fixture(&fixture_deferred); + app_deferred.set_defer_loads(true); + app_deferred.open_current(); + assert!( + app_deferred.open_pending(), + "open_current in defer mode must not load eagerly" + ); + + let mut app_eager = app_from_fixture(&fixture_eager); + app_eager.open_current(); + + let km = Keymap::defaults(); + let mut pending_deferred: Vec = Vec::new(); + let mut pending_eager: Vec = Vec::new(); + + // `j`: in defer mode this must force-complete the pending open (loading the view and + // re-deriving the cursor from the REAL first-hunk row) before applying the move — else + // the move would apply against the `0`-fallback cursor `reset_panes` left behind. + update( + &mut app_deferred, + &km, + &mut pending_deferred, + AppEvent::Key(key(KeyCode::Char('j'))), + ); + assert!( + !app_deferred.open_pending(), + "MoveCursorBy must force-complete the pending open" + ); + update( + &mut app_eager, + &km, + &mut pending_eager, + AppEvent::Key(key(KeyCode::Char('j'))), + ); + assert_eq!( + app_deferred.cursor, app_eager.cursor, + "post-completion cursor must match the eager path's cursor exactly" + ); + + // `s`: stages whatever hunk the (now-correct) cursor resolves to. + update( + &mut app_deferred, + &km, + &mut pending_deferred, + AppEvent::Key(key(KeyCode::Char('s'))), + ); + update( + &mut app_eager, + &km, + &mut pending_eager, + AppEvent::Key(key(KeyCode::Char('s'))), + ); + + let repo_deferred = fixture_deferred.repo().unwrap(); + let repo_eager = fixture_eager.repo().unwrap(); + repo_deferred.assert(predicate::repo::has_staged_file("a.txt")); + repo_eager.assert(predicate::repo::has_staged_file("a.txt")); + } } From 49cf4ee833f69525e2b91e00c83d56a118fe2a42 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 15:29:43 -0400 Subject: [PATCH 077/203] fix(review): reopen cached files eagerly in defer mode --- git-workon-review/src/app.rs | 66 ++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index faef754..d4e240e 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1427,13 +1427,17 @@ impl App { /// Load the current file's needed views and reset both panes to their first hunks. /// - /// In [`Self::defer_loads`] mode this does NOT load: it marks the open pending and resets the - /// panes anyway (the cursor falls back to row 0 for the still-unloaded view, via - /// [`Self::role_first_hunk`]'s `unwrap_or(0)` — harmless, since the body renders a placeholder - /// until [`Self::complete_pending_open`] runs). Outside defer mode this is exactly today's - /// eager behavior. + /// In [`Self::defer_loads`] mode a file whose views are NOT yet cached does not load here: + /// the open is marked pending and the panes reset anyway (the cursor falls back to row 0 + /// for the still-unloaded view, via [`Self::role_first_hunk`]'s `unwrap_or(0)` — harmless, + /// since the body renders a placeholder until [`Self::complete_pending_open`] runs). A file + /// whose views ARE cached takes the eager path even in defer mode: `ensure_loaded` is a + /// pure cache hit there, and deferring would only trade an instantly-renderable diff for a + /// placeholder flash lasting the debounce window — revisiting a file is the most common + /// navigation of all, and it must render immediately. Outside defer mode this is exactly + /// the pre-defer eager behavior. pub fn open_current(&mut self) { - if self.defer_loads { + if self.defer_loads && !self.current_views_cached() { self.open_pending = true; self.reset_panes(); return; @@ -1442,6 +1446,23 @@ impl App { self.reset_panes(); } + /// Whether the view(s) the current file's effective zoom needs are already cached, making a + /// deferred open pointless (`ensure_loaded` would be a cache hit). Split checks EITHER pane: + /// a role with no change for the file stays legitimately `None` forever (see + /// [`Self::ensure_role_loaded`]), so requiring both would defer a one-role file every time. + /// A partially-cached split (one loadable pane in, one missing) takes the eager path and + /// loads the single missing pane synchronously — one file, cheap, and consistent with the + /// both-`None` gate the render placeholder uses. + fn current_views_cached(&self) -> bool { + match self.effective_zoom_for(self.current) { + EffectiveZoom::Single(role) => self.role_view_ref(self.current, role).is_some(), + EffectiveZoom::Split => { + self.role_view_ref(self.current, Role::Unstaged).is_some() + || self.role_view_ref(self.current, Role::Staged).is_some() + } + } + } + /// Complete a deferred open, if one is pending: load the current file's needed views, then /// reset both panes again so the cursor now derives from the REAL first-hunk row (rather than /// the `0` fallback [`Self::open_current`] left it at). A no-op when nothing is pending — @@ -2939,6 +2960,39 @@ mod tests { assert_eq!(deferred_view.display.len(), eager_view.display.len()); } + #[test] + fn revisiting_a_cached_file_reopens_eagerly_without_a_pending_window() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "1\n2\n3\n", "1\nA\n3\n") + .unstaged_file("b.txt", "1\n2\n3\n", "1\nB\n3\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.set_defer_loads(true); + app.open_current(); // a.txt: uncached — defers + assert!(app.open_pending(), "an uncached file defers its open"); + app.complete_pending_open(); + + app.current = 1; + app.open_current(); // b.txt: uncached — defers + assert!(app.open_pending(), "a different uncached file still defers"); + app.complete_pending_open(); + + app.current = 0; + app.open_current(); // back to a.txt: cached — must NOT defer + assert!( + !app.open_pending(), + "revisiting a cached file must reopen eagerly — a pending window here would \ + flash the loading placeholder over an instantly-renderable diff" + ); + assert!( + app.current_view_ref().is_some(), + "the cached view is available the moment the open returns" + ); + } + #[test] fn complete_pending_open_is_a_no_op_when_nothing_pending() { let fixture = FixtureBuilder::new() From eb9f6400acbfb5705d58583642dc753f7171ba04 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 13:34:07 -0400 Subject: [PATCH 078/203] feat(review): show launch splash before changeset acquisition --- git-workon-review/src/main.rs | 36 +++++++++- git-workon-review/src/tui.rs | 123 ++++++++++++++++++++++++++++------ 2 files changed, 136 insertions(+), 23 deletions(-) diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index c000d9f..0fc73da 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -46,12 +46,36 @@ fn main() -> Result<()> { // `source` is kept (not just the resolved changesets) so it can be handed to `App` below — // `App::refresh` re-runs THIS same ask on every refresh rather than downgrading to // auto-detect (M7 CS2 fix). + // CS5: take the terminal and show launch activity BEFORE acquisition — resolve/diff can take + // a noticeable moment on a deep stack (or a PR source that hits the network), and until this + // point the launch left the terminal dead until acquisition finished. `Tui`'s Drop restores + // the terminal, so every `?` below puts the shell back before miette prints its error. + // + // An acquire FAILURE (no controlling tty — CI, a test harness, a bare pipe) is deferred, not + // propagated here: pre-CS5 the terminal was only taken inside the run call, so a tty-less + // "nothing to review" launch printed its message and exited 0 without ever needing a + // terminal. Carrying the `Result` until the run call preserves exactly that — the error + // surfaces at the same logical point it always did. Splash failures on an acquired terminal + // are cosmetic (the run call will surface anything real) and deliberately ignored. + let mut tui = tui::Tui::acquire(); + if let Ok(tui) = tui.as_mut() { + let _ = tui.splash("resolving changesets…"); + } + let source = cli.source.as_deref().map(Source::classify); let changesets = match &source { None => resolve_changesets(&repo, &branch).into_diagnostic()?, Some(source) => resolve_source(&repo, &branch, source.clone()).into_diagnostic()?, }; + if let Ok(tui) = tui.as_mut() { + let noun = if changesets.len() == 1 { + "changeset" + } else { + "changesets" + }; + let _ = tui.splash(&format!("diffing {} {noun}…", changesets.len())); + } let diffs = diff_changesets(&repo, &changesets).into_diagnostic()?; let views: Vec = changesets .into_iter() @@ -66,6 +90,12 @@ fn main() -> Result<()> { // exit 0 (ADR-030), never a `views` list handed to `App::from_changesets`, which panics on // empty input. if views.is_empty() || (views.len() == 1 && views[0].file_count() == 0) { + // Restore the terminal BEFORE printing (CS5): the message must land on the normal + // screen, not vanish with the alternate one. A tty-less launch has no terminal to + // restore — the message prints exactly as it did pre-CS5. + if let Ok(tui) = tui.as_mut() { + tui.restore().into_diagnostic()?; + } // Name the source when one was given (CS3) — a bare `nothing to review` would leave a // typo'd-but-empty range like `v1..v1` looking indistinguishable from the no-arg case. match cli.source.as_deref() { @@ -139,7 +169,11 @@ fn main() -> Result<()> { if probed { terminal_query::flush_pending_tty_input(); } - tui::run(&mut app, &keymap, &theme).into_diagnostic()?; + // A deferred acquire failure surfaces HERE — the same logical point (running the TUI) it + // surfaced at before CS5 moved the terminal takeover to the top of the launch. + tui.into_diagnostic()? + .run(&mut app, &keymap, &theme) + .into_diagnostic()?; Ok(()) } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 2497284..07b8c93 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -20,7 +20,9 @@ use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use ratatui::backend::CrosstermBackend; -use ratatui::Terminal; +use ratatui::style::{Modifier, Style}; +use ratatui::widgets::Paragraph; +use ratatui::{Frame, Terminal}; use workon_review::app::App; use workon_review::keymap::{Command, Dispatch, KeyPress, Keymap}; use workon_review::render; @@ -524,27 +526,84 @@ fn install_panic_hook() { })); } -/// Run the review TUI's terminal lifecycle and main loop against `app`. Callers must have already -/// called `app.open_current()` before calling this — under CS4's deferred-load mode -/// (`app.set_defer_loads(true)`, `main.rs`'s default) that call marks the open PENDING rather than -/// loading eagerly, so the first frame shows CS4's placeholder for one `OPEN_DEBOUNCE` window -/// instead of blocking startup on the initial file's load; a caller that never turned defer mode -/// on gets today's eager behavior unchanged. -pub fn run(app: &mut App, keymap: &Keymap, theme: &Palette) -> io::Result<()> { - install_panic_hook(); - enable_raw_mode()?; - let mut out = terminal_writer(); - execute!(out, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(out); - let mut terminal = Terminal::new(backend)?; - - let result = event_loop(&mut terminal, app, keymap, theme); - - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - - result +/// The acquired terminal: raw mode on, alternate screen entered, panic hook installed. +/// +/// Owning this as a value (rather than the old take-the-terminal-inside-`run` flow) is what lets +/// `main` show a splash frame BEFORE changeset acquisition — the terminal is live from the first +/// milliseconds of the launch, so resolve/diff work happens behind visible feedback instead of a +/// dead prompt. Restoration is idempotent and runs on [`Tui::restore`] or on drop, so every early +/// exit from `main` — "nothing to review", a `?`-propagated acquisition error — puts the shell +/// back before anything is printed to it. +pub struct Tui { + terminal: Terminal>>, + restored: bool, +} + +impl Tui { + /// Take over the terminal now: install the panic hook, enable raw mode, enter the alternate + /// screen. Call this before any slow launch work so [`Tui::splash`] can show it. + pub fn acquire() -> io::Result { + install_panic_hook(); + enable_raw_mode()?; + let mut out = terminal_writer(); + execute!(out, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(out); + let terminal = Terminal::new(backend)?; + Ok(Self { + terminal, + restored: false, + }) + } + + /// Draw a one-line launch-activity frame (e.g. `resolving changesets…`). Deliberately + /// theme-free (`DIM` modifier, no palette colors): it renders before the theme is resolved — + /// resolving the theme first would put the up-to-800ms `theme=auto` terminal probe back in + /// front of the first visible frame, defeating the point. + pub fn splash(&mut self, msg: &str) -> io::Result<()> { + self.terminal.draw(|f| draw_splash(f, msg))?; + Ok(()) + } + + /// Run the main loop against `app`, then restore the terminal. Callers must have already + /// called `app.open_current()` — under CS4's deferred-load mode (`app.set_defer_loads(true)`, + /// `main.rs`'s default) that call marks the open PENDING rather than loading eagerly, so the + /// first frame shows CS4's placeholder for one `OPEN_DEBOUNCE` window instead of blocking on + /// the initial file's load; a caller that never turned defer mode on gets eager behavior. + pub fn run(&mut self, app: &mut App, keymap: &Keymap, theme: &Palette) -> io::Result<()> { + let result = event_loop(&mut self.terminal, app, keymap, theme); + let restored = self.restore(); + result.and(restored) + } + + /// Put the terminal back (raw mode off, leave the alternate screen, cursor shown). Idempotent + /// — a second call (including the one [`Drop`] always makes) is a no-op, so explicit callers + /// (the "nothing to review" exit, which must restore BEFORE its `eprintln`) and the drop + /// backstop coexist without double-restoring. + pub fn restore(&mut self) -> io::Result<()> { + if self.restored { + return Ok(()); + } + self.restored = true; + disable_raw_mode()?; + execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?; + self.terminal.show_cursor() + } +} + +impl Drop for Tui { + /// Backstop restore for every exit path that doesn't call [`Tui::restore`] explicitly — most + /// importantly `main`'s `?` returns between `acquire` and `run`, whose errors miette prints + /// only after locals drop; without this they would print into the alternate screen. + fn drop(&mut self) { + let _ = self.restore(); + } +} + +/// Render the splash frame's widget tree — split from [`Tui::splash`] so tests can drive it +/// against a `TestBackend` frame without acquiring a real terminal. +fn draw_splash(frame: &mut Frame<'_>, msg: &str) { + let para = Paragraph::new(msg).style(Style::default().add_modifier(Modifier::DIM)); + frame.render_widget(para, frame.area()); } /// CS4's input-idle window: how long the loop waits with no new input before running a pending @@ -1872,4 +1931,24 @@ mod tests { repo_deferred.assert(predicate::repo::has_staged_file("a.txt")); repo_eager.assert(predicate::repo::has_staged_file("a.txt")); } + + // ── CS5: launch splash ──────────────────────────────────────────────────── + + #[test] + fn splash_renders_the_message() { + let backend = ratatui::backend::TestBackend::new(40, 3); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| draw_splash(f, "resolving changesets…")) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let top_row: String = (0..buffer.area.width) + .map(|x| buffer[(x, 0)].symbol()) + .collect(); + assert!( + top_row.contains("resolving changesets…"), + "splash frame must show the launch-activity message, got: {top_row:?}" + ); + } } From c50562e04725c9b94614dd52b31801a86cec5491 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 15:33:45 -0400 Subject: [PATCH 079/203] fix(review): keep terminal free during PR source resolution --- git-workon-review/src/main.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 0fc73da..3a30f6d 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -57,17 +57,31 @@ fn main() -> Result<()> { // terminal. Carrying the `Result` until the run call preserves exactly that — the error // surfaces at the same logical point it always did. Splash failures on an acquired terminal // are cosmetic (the run call will surface anything real) and deliberately ignored. - let mut tui = tui::Tui::acquire(); - if let Ok(tui) = tui.as_mut() { + // + // A PR source skips the early takeover entirely (`None` until after resolution): resolving + // a PR fetches over the network, and auth-git2 may interactively PROMPT on the controlling + // terminal for an ssh passphrase or https credentials when no agent/helper answers — inside + // raw-mode alternate screen that prompt would stair-step over the splash and leave the user + // typing blind. Those launches keep the pre-CS5 ordering: prompt (if any) on the normal + // screen, terminal taken right after resolution. + let source = cli.source.as_deref().map(Source::classify); + let mut tui = if matches!(source, Some(Source::Pr(_))) { + None + } else { + Some(tui::Tui::acquire()) + }; + if let Some(Ok(tui)) = tui.as_mut() { let _ = tui.splash("resolving changesets…"); } - let source = cli.source.as_deref().map(Source::classify); let changesets = match &source { None => resolve_changesets(&repo, &branch).into_diagnostic()?, Some(source) => resolve_source(&repo, &branch, source.clone()).into_diagnostic()?, }; + // The PR path's resolution (and any credential prompting) is done — take the terminal now. + let mut tui = tui.unwrap_or_else(tui::Tui::acquire); + if let Ok(tui) = tui.as_mut() { let noun = if changesets.len() == 1 { "changeset" From a4182e4565c6f598cd0111a6994fe3cb800c82be Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 15:49:26 -0400 Subject: [PATCH 080/203] fix(review): probe theme and resolve sources before terminal takeover --- git-workon-review/src/main.rs | 141 ++++++++++++++++++---------------- 1 file changed, 75 insertions(+), 66 deletions(-) diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 3a30f6d..50cf250 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -46,72 +46,34 @@ fn main() -> Result<()> { // `source` is kept (not just the resolved changesets) so it can be handed to `App` below — // `App::refresh` re-runs THIS same ask on every refresh rather than downgrading to // auto-detect (M7 CS2 fix). - // CS5: take the terminal and show launch activity BEFORE acquisition — resolve/diff can take - // a noticeable moment on a deep stack (or a PR source that hits the network), and until this - // point the launch left the terminal dead until acquisition finished. `Tui`'s Drop restores - // the terminal, so every `?` below puts the shell back before miette prints its error. // - // An acquire FAILURE (no controlling tty — CI, a test harness, a bare pipe) is deferred, not - // propagated here: pre-CS5 the terminal was only taken inside the run call, so a tty-less - // "nothing to review" launch printed its message and exited 0 without ever needing a - // terminal. Carrying the `Result` until the run call preserves exactly that — the error - // surfaces at the same logical point it always did. Splash failures on an acquired terminal - // are cosmetic (the run call will surface anything real) and deliberately ignored. - // - // A PR source skips the early takeover entirely (`None` until after resolution): resolving - // a PR fetches over the network, and auth-git2 may interactively PROMPT on the controlling - // terminal for an ssh passphrase or https credentials when no agent/helper answers — inside - // raw-mode alternate screen that prompt would stair-step over the splash and leave the user - // typing blind. Those launches keep the pre-CS5 ordering: prompt (if any) on the normal - // screen, terminal taken right after resolution. + // Everything from here through the theme probe runs BEFORE the terminal is taken (CS5's + // splash enters the alternate screen further down, for the diff/build phase only). That + // ordering is deliberate, not incidental: + // - PR resolution fetches over the network, and auth-git2 may interactively PROMPT for an + // ssh passphrase / https credentials — inside raw-mode alternate screen the prompt would + // stair-step over the splash and leave the user typing blind. + // - "nothing to review" exits below without ever needing a tty (CI, test harnesses). + // - The `theme = auto` probe must own the tty while it converses, and its straggler flush + // discards ALL pending input — flushing before the alternate screen appears means nothing + // a user types at a visible TUI is ever eaten (a q typed right after the screen flips + // must quit, not vanish; see pty_smoke.rs's silent-terminal test). + // Resolve itself is milliseconds locally, so the splash still appears near-instantly for + // the launch that matters (a deep stack's diff work, below). let source = cli.source.as_deref().map(Source::classify); - let mut tui = if matches!(source, Some(Source::Pr(_))) { - None - } else { - Some(tui::Tui::acquire()) - }; - if let Some(Ok(tui)) = tui.as_mut() { - let _ = tui.splash("resolving changesets…"); - } - let changesets = match &source { None => resolve_changesets(&repo, &branch).into_diagnostic()?, Some(source) => resolve_source(&repo, &branch, source.clone()).into_diagnostic()?, }; - // The PR path's resolution (and any credential prompting) is done — take the terminal now. - let mut tui = tui.unwrap_or_else(tui::Tui::acquire); - - if let Ok(tui) = tui.as_mut() { - let noun = if changesets.len() == 1 { - "changeset" - } else { - "changesets" - }; - let _ = tui.splash(&format!("diffing {} {noun}…", changesets.len())); - } - let diffs = diff_changesets(&repo, &changesets).into_diagnostic()?; - let views: Vec = changesets - .into_iter() - .zip(diffs) - .map(|(cs, diff)| ChangesetView::from_changeset_diff(cs, diff)) - .collect(); - // A resolved source can legitimately name zero changesets — `stack` on a branch that's // caught up with its upstream and has a clean tree hits `assemble_git`'s empty-vec arm // (see `git_inference_caught_up_and_clean_returns_empty` in git-workon-lib), same as the // single-uncommitted-changeset case with nothing in it. Both are "nothing to review" + - // exit 0 (ADR-030), never a `views` list handed to `App::from_changesets`, which panics on - // empty input. - if views.is_empty() || (views.len() == 1 && views[0].file_count() == 0) { - // Restore the terminal BEFORE printing (CS5): the message must land on the normal - // screen, not vanish with the alternate one. A tty-less launch has no terminal to - // restore — the message prints exactly as it did pre-CS5. - if let Ok(tui) = tui.as_mut() { - tui.restore().into_diagnostic()?; - } - // Name the source when one was given (CS3) — a bare `nothing to review` would leave a - // typo'd-but-empty range like `v1..v1` looking indistinguishable from the no-arg case. + // exit 0 (ADR-030). The file-count gate needs per-changeset counts, not views — checked + // against the resolved changesets' diffs only after they're built, so the empty case is + // detected on the cheap resolve data here first. + if changesets.is_empty() { match cli.source.as_deref() { Some(text) => eprintln!("nothing to review in {text}"), None => eprintln!("nothing to review"), @@ -146,6 +108,62 @@ fn main() -> Result<()> { // would still be borrowing `repo` when `App::from_changesets` tries to move it below). let view_config = ReviewConfig::new(&repo).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 + // takes the terminal — parsed as input they become phantom keystrokes (`r` fires refreshes; + // `d` opens the discard confirm, which then swallows every key until Esc/n: the + // "unresponsive for ~30s with theme=auto" startup). Un-probed launches skip this so + // legitimate type-ahead survives. This MUST stay ahead of `Tui::acquire`: once the + // alternate screen is visible, a user's keystrokes are real input a flush must never eat. + if probed { + terminal_query::flush_pending_tty_input(); + } + + // CS5: take the terminal and show launch activity while the diffs build — on a deep stack + // this is the bulk of the launch, and until CS5 it left the terminal dead the whole time. + // Everything that could print, prompt, or flush is done (see the block comment above the + // resolve), so from here the terminal belongs to the TUI. `Tui`'s Drop restores it, so the + // `?`s below put the shell back before miette prints their error. + // + // An acquire FAILURE (no controlling tty — CI, a test harness, a bare pipe) is carried, not + // propagated here: a clean worktree's "nothing to review" is only detectable AFTER the diff + // below (resolve always yields at least the uncommitted changeset), and that exit must stay + // tty-free, exactly as it was when the terminal was only taken inside the run call. The + // error surfaces at the run call — the same logical point it always did. Splash failures on + // an acquired terminal are cosmetic (the run call will surface anything real) and ignored. + let mut tui = tui::Tui::acquire(); + if let Ok(tui) = tui.as_mut() { + let noun = if changesets.len() == 1 { + "changeset" + } else { + "changesets" + }; + let _ = tui.splash(&format!("diffing {} {noun}…", changesets.len())); + } + let diffs = diff_changesets(&repo, &changesets).into_diagnostic()?; + let views: Vec = changesets + .into_iter() + .zip(diffs) + .map(|(cs, diff)| ChangesetView::from_changeset_diff(cs, diff)) + .collect(); + + // The single-uncommitted-changeset case with nothing in it only shows up in the built + // views' file counts — the mirror of the resolve-level empty check above, and the same + // "nothing to review" + exit 0 (ADR-030), never a `views` list handed to + // `App::from_changesets`, which panics on empty input. Restore the terminal BEFORE + // printing: the message must land on the normal screen, not vanish with the alternate one. + // A tty-less launch has no terminal to restore — the message prints exactly as before CS5. + if views.is_empty() || (views.len() == 1 && views[0].file_count() == 0) { + if let Ok(tui) = tui.as_mut() { + tui.restore().into_diagnostic()?; + } + match cli.source.as_deref() { + Some(text) => eprintln!("nothing to review in {text}"), + None => eprintln!("nothing to review"), + } + return Ok(()); + } + // `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). @@ -174,17 +192,8 @@ fn main() -> Result<()> { app.notify(warnings.join("; "), Severity::Error); } - // After a probe, OSC replies from a slow terminal (e.g. one ssh round-trip away) may have - // straggled in while the changesets were being assembled above. Discard them now, right - // before crossterm takes the terminal — parsed as input they become phantom keystrokes - // (`r` fires refreshes; `d` opens the discard confirm, which then swallows every key until - // Esc/n: the "unresponsive for ~30s with theme=auto" startup). Un-probed launches skip this - // so legitimate type-ahead survives. - if probed { - terminal_query::flush_pending_tty_input(); - } - // A deferred acquire failure surfaces HERE — the same logical point (running the TUI) it - // surfaced at before CS5 moved the terminal takeover to the top of the launch. + // 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) .into_diagnostic()?; From 51174f46f99664373914a65f35ee7a5432f85122 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 15:03:18 -0400 Subject: [PATCH 081/203] test(review): PTY responsiveness harness for launch and nav burst --- git-workon-review/tests/pty_responsiveness.rs | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 git-workon-review/tests/pty_responsiveness.rs diff --git a/git-workon-review/tests/pty_responsiveness.rs b/git-workon-review/tests/pty_responsiveness.rs new file mode 100644 index 0000000..4c8bfd2 --- /dev/null +++ b/git-workon-review/tests/pty_responsiveness.rs @@ -0,0 +1,173 @@ +//! PTY responsiveness smoke tests for the 2026-07 performance pass — the launch path and the +//! rapid-outline-nav path, driven against the real binary in a pseudo-terminal. +//! +//! These guard the *regression classes* that pass fixed, not the milliseconds it measured: +//! +//! - **Launch:** startup once spent ~370ms spawning `gt --version` (a Node CLI) inside +//! `StackModel::detect`, and ~230ms diffing the whole stack sequentially, all before the first +//! frame. The launch test bounds spawn→quit so a reintroduced subprocess spawn or blocking +//! probe in the launch path fails loudly. +//! - **Nav burst:** every outline `j` once ran a synchronous `FileView::load` (blob reads, +//! alignment, two whole-file tree-sitter passes) for each file it crossed — 10-100ms per key. +//! The burst test buffers a 40-key sweep over ~two dozen large Rust files and bounds +//! burst→quit; if input coalescing (`update_batch`) or idle-deferred loads +//! (`open_pending`/`OPEN_DEBOUNCE`) regress, the quit waits behind the sum of every +//! intermediate file's load and blows the bound. +//! +//! The bounds are deliberately blunt (seconds, not milliseconds): absolute wall-clock +//! assertions flake under parallel CPU load, exactly like git-workon's +//! `checkout_conflict_interactive_*` PTY test — re-run solo before treating a failure as a +//! regression. Precise per-phase timings stay a manual workflow (temporary instrumentation + +//! an expect(1) driver), not CI assertions. +//! +//! **Not run by default** (`#[ignore]`) for the same wall-clock reasons as `pty_smoke.rs`. Run +//! explicitly: +//! +//! ```text +//! cargo test -p git-workon-review --test pty_responsiveness -- --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)] + +use std::time::{Duration, Instant}; + +use expectrl::{ + session::{OsProcess, OsStream}, + Expect, Session, +}; +use git_workon_fixture::prelude::*; + +/// Upper bound on spawn→quit for a healthy launch (~120ms release, well under a second in +/// debug). Generous on purpose: the regression classes cost multiple seconds (a Node spawn per +/// detection, a sequential stack diff), and the slack absorbs CI load. +const LAUNCH_RESPONSIVE: Duration = Duration::from_secs(5); + +/// Upper bound on burst-sent→quit. Healthy is near-instant: the burst coalesces to one outline +/// move, loads defer past the buffered `q`, and the app exits without ever loading the +/// intermediate files. The regressed shape loads every file the sweep crossed. The bound was +/// sized against measurements of BOTH sides on this fixture in a debug build: the actual +/// pre-fix code (`m7-complete`, one synchronous load per outline row) took ~2.6s; the fixed +/// code ~120ms. 1s sits ~8× above healthy and ~2.5× below regressed. +const BURST_RESPONSIVE: Duration = Duration::from_secs(1); + +/// How many generated Rust files the nav-burst fixture carries, and how the burst is sized: +/// enough files (and enough lines per file — see `BURST_FILE_LINES`) that a load-per-key +/// regression accumulates seconds of tree-sitter work, few enough that fixture setup stays +/// cheap. +const BURST_FILES: usize = 36; + +/// Lines per generated fixture file — see `BURST_FILES`. +const BURST_FILE_LINES: usize = 2_000; + +/// Spawn the review binary in a PTY sized like a real terminal (an unsized PTY is 0×0 and +/// ratatui draws nothing), cwd'd into the fixture's worktree. Mirrors `pty_smoke.rs`. +fn spawn_review(fixture: &Fixture) -> Session { + let repo = fixture.repo().expect("fixture repo"); + let workdir = repo.workdir().expect("fixture workdir").to_path_buf(); + + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_git-workon-review")); + cmd.current_dir(workdir).env("TERM", "xterm-256color"); + + let mut session = expectrl::Session::spawn(cmd).expect("spawn in PTY"); + session + .get_process_mut() + .set_window_size(120, 40) + .expect("size PTY"); + session.set_expect_timeout(Some(Duration::from_secs(15))); + session +} + +/// A plausible-enough Rust source of ~`lines` lines, distinct per `seed`, so the tree-sitter +/// highlighter has real parsing work per file (the regression cost being guarded). +fn rust_source(seed: usize, lines: usize) -> String { + let mut src = String::with_capacity(lines * 40); + src.push_str(&format!("//! Generated fixture module {seed}.\n\n")); + let mut n = 0; + while src.lines().count() < lines { + src.push_str(&format!( + "pub fn item_{seed}_{n}(x: u64) -> u64 {{\n let y = x.wrapping_mul({n}) + {seed};\n y ^ (y >> 3)\n}}\n\n", + )); + n += 1; + } + src +} + +#[test] +#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty_responsiveness -- --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 + // TUI actually opens; a plain (non-Graphite) repo keeps behavior identical whether or not + // the machine has `gt` on PATH — and `StackModel::detect` still runs `detect_gt` first, so + // a reintroduced subprocess spawn there is still inside the measured window. + let fixture = FixtureBuilder::new() + .config("workon.review.theme", "dark") + .unstaged_file("file.txt", "a\nb\nc\n", "a\nCHANGED\nc\n") + .build() + .expect("fixture"); + + let launched = Instant::now(); + let mut session = spawn_review(&fixture); + + // The alternate screen is the proof the launch reached the TUI — without this, an early + // error exit (or "nothing to review") would sail through the quit assertion trivially. + session + .expect("\x1b[?1049h") + .expect("TUI entered the alternate screen"); + + // `q` buffers in the PTY until the event loop polls input, so send it immediately: the + // elapsed spawn→exit time IS time-to-interactive plus one quit. + session.send("q").expect("send q"); + session.expect(expectrl::Eof).expect("app exited on q"); + + let elapsed = launched.elapsed(); + assert!( + elapsed < LAUNCH_RESPONSIVE, + "launch→quit took {elapsed:?} — something slow is blocking the launch path \ + (subprocess spawn? sequential stack diff? probe?)" + ); +} + +#[test] +#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty_responsiveness -- --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. + let mut builder = FixtureBuilder::new().config("workon.review.theme", "dark"); + let sources: Vec<(String, String)> = (0..BURST_FILES) + .map(|i| (format!("src_{i:02}.rs"), rust_source(i, BURST_FILE_LINES))) + .collect(); + for (path, content) in &sources { + builder = builder.untracked_file(path, content); + } + let fixture = builder.build().expect("fixture"); + + let mut session = spawn_review(&fixture); + session + .expect("\x1b[?1049h") + .expect("TUI entered the alternate screen"); + + // Buffer the whole interaction at once — focus the outline, sweep down across every file + // row, quit. This is the buffered-burst shape the coalescing fix exists for: healthy code + // merges the sweep into one outline move and quits before any deferred load fires; + // regressed code loads each file it crosses before it ever reaches the `q`. + let burst_sent = Instant::now(); + let mut input = String::from("o"); + input.push_str(&"j".repeat(BURST_FILES + 12)); // sweep past every file row, clamp at the end + input.push('q'); + session.send(&input).expect("send nav burst"); + session.expect(expectrl::Eof).expect("app exited on q"); + + let elapsed = burst_sent.elapsed(); + // Visible under `--nocapture`; also the number to check when triaging a failure. + eprintln!("burst→quit: {elapsed:?}"); + assert!( + elapsed < BURST_RESPONSIVE, + "burst→quit took {elapsed:?} — outline nav is loading files synchronously again \ + (input coalescing or idle-deferred loads regressed)" + ); +} From e8d387dbeb96f6c88d4937161940c18a7ce92078 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 9 Jul 2026 15:50:24 -0400 Subject: [PATCH 082/203] refactor(review): share the PTY spawn helper across test suites --- git-workon-review/tests/pty_responsiveness.rs | 26 +++------------ git-workon-review/tests/pty_smoke.rs | 21 ++---------- git-workon-review/tests/pty_support/mod.rs | 32 +++++++++++++++++++ 3 files changed, 39 insertions(+), 40 deletions(-) create mode 100644 git-workon-review/tests/pty_support/mod.rs diff --git a/git-workon-review/tests/pty_responsiveness.rs b/git-workon-review/tests/pty_responsiveness.rs index 4c8bfd2..a1b0fcd 100644 --- a/git-workon-review/tests/pty_responsiveness.rs +++ b/git-workon-review/tests/pty_responsiveness.rs @@ -33,12 +33,12 @@ #![cfg(unix)] +mod pty_support; +use pty_support::spawn_review; + use std::time::{Duration, Instant}; -use expectrl::{ - session::{OsProcess, OsStream}, - Expect, Session, -}; +use expectrl::Expect; use git_workon_fixture::prelude::*; /// Upper bound on spawn→quit for a healthy launch (~120ms release, well under a second in @@ -63,24 +63,6 @@ const BURST_FILES: usize = 36; /// Lines per generated fixture file — see `BURST_FILES`. const BURST_FILE_LINES: usize = 2_000; -/// Spawn the review binary in a PTY sized like a real terminal (an unsized PTY is 0×0 and -/// ratatui draws nothing), cwd'd into the fixture's worktree. Mirrors `pty_smoke.rs`. -fn spawn_review(fixture: &Fixture) -> Session { - let repo = fixture.repo().expect("fixture repo"); - let workdir = repo.workdir().expect("fixture workdir").to_path_buf(); - - let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_git-workon-review")); - cmd.current_dir(workdir).env("TERM", "xterm-256color"); - - let mut session = expectrl::Session::spawn(cmd).expect("spawn in PTY"); - session - .get_process_mut() - .set_window_size(120, 40) - .expect("size PTY"); - session.set_expect_timeout(Some(Duration::from_secs(15))); - session -} - /// A plausible-enough Rust source of ~`lines` lines, distinct per `seed`, so the tree-sitter /// highlighter has real parsing work per file (the regression cost being guarded). fn rust_source(seed: usize, lines: usize) -> String { diff --git a/git-workon-review/tests/pty_smoke.rs b/git-workon-review/tests/pty_smoke.rs index 070b2a6..5b72c5f 100644 --- a/git-workon-review/tests/pty_smoke.rs +++ b/git-workon-review/tests/pty_smoke.rs @@ -22,6 +22,9 @@ #![cfg(unix)] +mod pty_support; +use pty_support::spawn_review; + use std::io::Write; use std::time::{Duration, Instant}; @@ -45,24 +48,6 @@ fn auto_theme_fixture() -> Fixture { .expect("fixture") } -/// Spawn the review binary in a PTY sized like a real terminal (an unsized PTY is 0×0 and -/// ratatui draws nothing), cwd'd into the fixture's worktree. -fn spawn_review(fixture: &Fixture) -> Session { - let repo = fixture.repo().expect("fixture repo"); - let workdir = repo.workdir().expect("fixture workdir").to_path_buf(); - - let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_git-workon-review")); - cmd.current_dir(workdir).env("TERM", "xterm-256color"); - - let mut session = expectrl::Session::spawn(cmd).expect("spawn in PTY"); - session - .get_process_mut() - .set_window_size(120, 40) - .expect("size PTY"); - session.set_expect_timeout(Some(Duration::from_secs(15))); - session -} - /// Play a well-behaved answering terminal: reply to all 16 `OSC 4` color queries plus /// `OSC 11`/`OSC 10`, then the DA1 sentinel. The replies deliberately contain the poison bytes /// of the round-2 wedge — `r`/`g`/`b` (refresh binding) and `d` hex digits (discard binding) — diff --git a/git-workon-review/tests/pty_support/mod.rs b/git-workon-review/tests/pty_support/mod.rs new file mode 100644 index 0000000..1253c86 --- /dev/null +++ b/git-workon-review/tests/pty_support/mod.rs @@ -0,0 +1,32 @@ +//! Shared PTY-test support for the `pty_smoke` and `pty_responsiveness` test binaries. +//! +//! 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. + +use std::time::Duration; + +use expectrl::{ + session::{OsProcess, OsStream}, + Session, +}; +use git_workon_fixture::prelude::*; + +/// Spawn the review binary in a PTY sized like a real terminal (an unsized PTY is 0×0 and +/// ratatui draws nothing), cwd'd into the fixture's worktree. +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 mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_git-workon-review")); + cmd.current_dir(workdir).env("TERM", "xterm-256color"); + + let mut session = expectrl::Session::spawn(cmd).expect("spawn in PTY"); + session + .get_process_mut() + .set_window_size(120, 40) + .expect("size PTY"); + session.set_expect_timeout(Some(Duration::from_secs(15))); + session +} From 7cb4cd044ad63fe8c3310d054e8b74735d889805 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 00:19:29 -0400 Subject: [PATCH 083/203] docs(review): ADR-031 progressive pipeline design --- docs/adr/031-review-progressive-pipeline.md | 145 ++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/adr/031-review-progressive-pipeline.md diff --git a/docs/adr/031-review-progressive-pipeline.md b/docs/adr/031-review-progressive-pipeline.md new file mode 100644 index 0000000..8444446 --- /dev/null +++ b/docs/adr/031-review-progressive-pipeline.md @@ -0,0 +1,145 @@ +# 031 — Review TUI: Progressive Pipeline (Threads, Streaming Acquisition, Generation-Tagged Loads) + +Status: accepted (2026-07-10, progressive-pipeline design session) + +## Context + +The M7 performance pass (perf-gt-detect … perf-pty-responsiveness) removed the worst +launch and navigation stalls, but two synchronous gaps remain: the idle-deferred file load +runs on the event-loop thread (a huge file holds input hostage for its own load once the +80ms debounce fires), and startup diffs complete in full — behind the splash, but not +streamed — before the outline appears. Closing them means work moves off the event-loop +thread, which **supersedes M4's locked decision #4** ("a synchronous poll on the existing +`Tick`… No threads, no `mpsc`, no new deps" — recorded in `tui.rs`'s module doc, not an +ADR). This ADR retires the "no threads" letter of that decision while keeping its "no new +deps" spirit: everything below is `std::sync::mpsc` + `std::thread`. Zero new dependencies. + +Scope: streamed startup acquisition, off-thread file loads, a dedicated input thread, and +the refresh path riding the same pipeline (one acquisition path, not two). Explicitly out +of scope: overlapping the `theme=auto` terminal probe with acquisition (deprioritized; a +later changeset can reuse this seam). Nothing here reorders startup ahead of the theme +probe / `flush_pending_tty_input` sequence — that ordering is load-bearing (see the +pty_smoke silent-terminal canary). + +## Decision + +**Topology — three permanent threads plus transient wave workers.** The *main thread* +owns `App`, rendering, and every repo **write** (staging stays synchronous). The *input +thread* is the sole reader of terminal events: a blocking `crossterm::event::read()` loop +forwarding into the inbox. The *loader thread* owns its own long-lived `Repository` + +`TsHighlighter`; it serves file-load requests sequentially and, for a whole-stack diff +wave (startup, refresh), spawns a transient scoped worker pool — per-worker `Repository`, +exactly today's `diff_changesets` striping — but **streams each changeset's result as it +completes** instead of joining the batch. The parallel-diff win and streaming compose. + +**Protocol — one inbox, stateless loader.** A single `mpsc` inbox feeds the main loop; +`recv_timeout` replaces `event::poll`, and the timeout *is* the Tick beat (index-watcher +poll and the 80ms open-debounce survive unchanged as timeout arms). `AppEvent` grows +loader-result variants — `ChangesetReady { gen, idx, result }` and +`FileReady { gen, cs_idx, file_idx, views }` — and loses `derive(Copy)`. `drain_pending` +becomes a `try_recv` loop, so nav coalescing in `update_batch` carries over untouched. The +loader is stateless between jobs: each request carries what it needs (cloned `FileChange` + +span; content is read through the loader's own repo handle). `App` stays the single owner +of diff truth — no second copy of the stack to keep coherent across refreshes. + +**Generations — one global `u64`, mismatch is the only drop rule.** The invariant: +*generation bumps ⟺ the view caches were invalidated* (launch is gen 1; every refresh +bumps). Requests are stamped at send; results carry the stamp; the main loop discards +mismatches at one chokepoint. Within a generation every `FileReady` is cached **even if +the user navigated away** — the diff hasn't changed, so an early result is warmth, not +staleness (A→B→A bounces land on a warm A). The loader never decides staleness. +Per-changeset generations were rejected: refresh rebuilds view caches wholesale, so finer +tags would model granularity the app doesn't have. + +**Slots — `Pending | Ready | Failed` per changeset.** `App` is constructible from +resolved-but-undiffed changesets: all slots `Pending`, `current_cs` from lib-`current` +(metadata only), outline headers render immediately and file rows fill in per +`ChangesetReady`. Navigating onto a `Pending` changeset shows the existing placeholder +treatment. Waves diff the **current changeset first**, then input order — the changeset +the user lands on becomes interactive earliest, and the splash becomes redundant for +stacks (the first real frame is the live outline). + +**The lone-changeset launch stays synchronous.** `main.rs` forks on `changesets.len()`: +one changeset (non-Graphite default, ref/range, PR) keeps today's sync diff + empty-check ++ splash byte-identical. Streaming's grain is per-changeset, so a 1-changeset review gains +nothing from it — and the "nothing to review" exit-0 must stay tty-free (the +`clean_worktree_prints_nothing_to_review_and_exits_success` canary runs with no terminal; +an in-TUI empty-detection can never serve it). Consequently `App::from_changesets`'s +≥1 assert survives unchanged. + +**Force-completion — synchronous fallback on the main thread.** The `apply_action` +chokepoint keeps its meaning: an action that reads the view (`s`, cursor moves, selection) +finds the cache warm or loads *synchronously right there* — `App` keeps its own +`Repository` + `TsHighlighter` for exactly this and for staging. The in-flight loader +result later hits "already cached" and is discarded. The loader is thereby a **pure +cache-warmer: correctness never depends on it**, and the CS4 invariant (deferred-then- +completed open ≡ eager open, byte-identical) survives trivially. Accepted cost: `s` on a +just-reached huge file can still block for that file's load — the price of byte-identical +action semantics without action-replay machinery (queueing actions until `FileReady` was +rejected: replay ordering hazards for a rare case). Highlight determinism across the two +highlighter instances holds — highlighting is a pure function of content + grammar +(ADR-029's theme-free design). + +**Refresh — sync resolve, span-keyed reuse, uncommitted always sync.** Resolve stays on +the main thread (offline, cheap; PR sources remain refresh-no-ops). The rebuilt view list +carries over any `Ready` slot whose `(name, span)` is unchanged — a committed diff is a +pure function of its span — so an ordinary post-staging refresh re-diffs *nothing but the +uncommitted layer*, and a restack streams only what moved ("never blank" holds by +construction: stale-but-present content renders until replaced). The **uncommitted layer +always re-diffs synchronously** in every refresh: it is ms-scale, and this preserves +staging's guarantee that the next keystroke sees the post-op world — an async refresh +would let a second `s` compute its patch against a stale diff. One refresh shape; no +staging-vs-manual modes. Every refresh bumps the generation (reused-slot in-flight loads +die valid at the inbox; accepted waste for one global rule). + +**Failures — per-changeset degradation for stacks, fatal only where it's the whole +review.** `ChangesetReady { result: Err }` sets that slot `Failed`: the outline marks it, +navigating to it renders the error, the wave's first failure raises a footer notice, and +the review continues (34 reviewable changesets beat zero). The lone-changeset sync path +keeps today's pre-loop fatal miette exit. `r` is the retry — span reuse only carries +`Ready` slots. Contract change accepted: a stack review with one corrupt changeset now +exits 0 on quit where it previously died non-zero; the tty-less paths are unchanged. + +**Lifecycle — kill-on-exit, one justified `catch_unwind`.** No join on quit: the loader +never writes, so killing it mid-read corrupts nothing, and a join only adds quit lag. The +loader wraps each job in `catch_unwind`, converting a job panic into a `Failed` result — +the specific class this catches that nothing else does: a panicked job silently drops into +slots stranded `Pending` forever (the inbox stays connected via the input thread's +sender), an invisible hang instead of a visible error. Input-thread read errors are +forwarded into the inbox and exit the loop as `io::Error`, same observable behavior as +today. + +**Rejected: an async runtime (Tokio).** It relocates this complexity rather than removing +it: every job is blocking (libgit2 C calls, tree-sitter CPU, tty reads), so all work lands +in `spawn_blocking` — the same threads plus a runtime. `select!` buys nothing over the +single-inbox `recv_timeout`; future-cancellation cannot interrupt blocking work and +doesn't replace generation tags (the race is results-already-computed, not work-in- +flight); and the force-completion *synchronous* fallback — load-bearing for staging +correctness — is trivial in sync code and a genuine problem inside a task. The hard parts +of this design are state-model decisions that survive any executor. + +**Testing — real threads confined to one smoke layer.** (1) The loader job body is a pure +function `LoadRequest → AppEvent`, unit-tested synchronously (diff correctness, error +wrapping, panic-to-`Failed`). (2) Loop behavior is tested by feeding synthetic event +sequences through `update_batch` — slot transitions, gen drops, within-gen cache-after- +nav-away, span reuse, and the carried eager-equivalence invariant — no threads, no flake +surface. (3) One real-thread integration smoke plus a `pty_responsiveness` extension +asserting the first interactive frame lands before a full wave could have finished +(`#[ignore]`, run solo, per the existing wall-clock caveat). Existing eager-mode tests +stay untouched (defer off, slots constructed `Ready`). + +## Consequences + +- `tui.rs`'s module doc note pinning M4 locked decision #4 must be rewritten to point + here; the M4 index-watcher *semantics* (signature compare on the tick beat, echo + suppression) are unchanged — only the beat's mechanism moves from `event::poll` timeout + to `recv_timeout`. +- `AppEvent` stops being `Copy`; `drain_pending`/`next_event` reshape around the inbox; + the input thread becomes the only code that touches crossterm's event API. +- The splash survives only on the lone-changeset path; for stacks the first frame is the + live outline with `Pending` rows. +- Two visible behavior changes, both accepted: post-restack refreshes show placeholders + for moved changesets while unmoved ones stay readable (today the whole UI freezes), and + a corrupt changeset in a stack degrades to a `Failed` row instead of killing the launch. +- `App` and the loader each hold a `TsHighlighter`; grammar caches are duplicated + per-instance (modest, accepted for the sync-fallback guarantee). From 865a574ee4dbd739009ae2e3984693183ba8fc11 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 00:42:12 -0400 Subject: [PATCH 084/203] feat(review): per-changeset Pending/Ready/Failed slots --- git-workon-review/src/app.rs | 196 ++++++++++++++++++++++++++++++- git-workon-review/src/outline.rs | 71 +++++++++++ git-workon-review/src/render.rs | 28 +++++ 3 files changed, 293 insertions(+), 2 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index d4e240e..a744331 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -604,6 +604,27 @@ pub struct ChangesetView { views_combined: Vec>, views_unstaged: Vec>, views_staged: Vec>, + /// ADR-031's per-changeset acquisition state. `Ready` for every changeset this changeset + /// (this revision of the codebase) actually diffs through; `Pending`/`Failed` slots are + /// constructible today (state model + rendering) but nothing in the synchronous startup/ + /// refresh paths produces them yet — that lands with the streamed-acquisition changesets. + slot: ChangesetSlot, +} + +/// A [`ChangesetView`]'s acquisition state (ADR-031's "Slots" decision). `diff`/the `views_*` +/// caches stay meaningful only for `Ready` — a `Pending`/`Failed` view's [`DiffState`] is always +/// [`DiffState::empty`], so every existing `.diff.`-reading call site (file counts, outline +/// rows, nav guards) already treats it as "nothing to show" with no per-site branch needed; only +/// the render/outline paths that must actively DISTINGUISH the three states (vs. a genuinely +/// empty `Ready` changeset) read this directly. +#[derive(Debug, Clone, PartialEq, Eq)] +enum ChangesetSlot { + /// Acquisition hasn't run (or hasn't completed) for this changeset yet. + Pending, + /// The diff (and its view caches) are real. + Ready, + /// The acquisition attempt errored; the message is shown in place of a diff body. + Failed(String), } impl ChangesetView { @@ -615,6 +636,43 @@ impl ChangesetView { views_combined: (0..n).map(|_| None).collect(), views_unstaged: (0..n).map(|_| None).collect(), views_staged: (0..n).map(|_| None).collect(), + slot: ChangesetSlot::Ready, + } + } + + /// Construct a `Pending` slot for `cs` (ADR-031): no diff acquired yet. The outline shows + /// its header with a loading indication; navigating onto it renders a changeset-level + /// placeholder instead of "(no changes)"/per-file content. + pub fn pending(cs: Changeset) -> Self { + let mut view = Self::new(cs, DiffState::empty()); + view.slot = ChangesetSlot::Pending; + view + } + + /// Construct a `Failed` slot for `cs` carrying `message` (ADR-031): the acquisition attempt + /// for this changeset errored. The outline marks it; navigating onto it renders `message` + /// instead of a diff body. + pub fn failed(cs: Changeset, message: impl Into) -> Self { + let mut view = Self::new(cs, DiffState::empty()); + view.slot = ChangesetSlot::Failed(message.into()); + view + } + + /// Whether this changeset's diff hasn't been acquired yet (ADR-031). + pub fn is_pending(&self) -> bool { + matches!(self.slot, ChangesetSlot::Pending) + } + + /// Whether this changeset's acquisition attempt errored (ADR-031). + pub fn is_failed(&self) -> bool { + matches!(self.slot, ChangesetSlot::Failed(_)) + } + + /// This changeset's failure message, if [`Self::is_failed`] — `None` for `Pending`/`Ready`. + pub fn failure_message(&self) -> Option<&str> { + match &self.slot { + ChangesetSlot::Failed(msg) => Some(msg.as_str()), + ChangesetSlot::Pending | ChangesetSlot::Ready => None, } } @@ -991,6 +1049,19 @@ impl App { self.current_cs } + /// Whether the ACTIVE changeset's slot is `Pending` (ADR-031) — `render.rs`'s body path + /// shows a changeset-level loading placeholder instead of "(no changes)"/per-file content + /// while this holds. + pub fn is_current_pending(&self) -> bool { + self.cur().is_pending() + } + + /// The ACTIVE changeset's failure message, if its slot is `Failed` (ADR-031) — `render.rs`'s + /// body path shows this instead of a diff body. + pub fn current_failure(&self) -> Option<&str> { + self.cur().failure_message() + } + /// The active changeset's descriptor (name, source, restack status) — read by tests /// asserting which changeset [`Self::current_cs`] landed on. pub fn current_changeset(&self) -> &Changeset { @@ -1640,6 +1711,8 @@ impl App { label: v.cs.title.clone().unwrap_or_else(|| v.cs.name.clone()), current: v.cs.current, needs_restack: v.cs.needs_restack, + loading: v.is_pending(), + failed: v.is_failed(), files: v .files() .iter() @@ -2611,6 +2684,20 @@ impl From for DiffState { } impl DiffState { + /// An empty [`DiffState`] — every field zero-length. Used for `Pending`/`Failed` + /// [`ChangesetView`] slots (ADR-031), which carry no real diff; existing `.diff.` read sites + /// already treat an empty `files` list as "nothing to show," so this alone is enough to make + /// those slots render/navigate as inert with no per-site Pending/Failed branch. + fn empty() -> Self { + Self { + files: Vec::new(), + unstaged_model: DiffModel { files: Vec::new() }, + staged_model: DiffModel { files: Vec::new() }, + unstaged_idx: Vec::new(), + staged_idx: Vec::new(), + } + } + /// Build a [`DiffState`] for a COMMITTED changeset's [`DiffModel`] (`base..head`, already /// diffed by [`crate::acquire::diff_committed`]) — there is no staged/unstaged split for a /// committed range, so both sub-models are empty and every index map entry is `None`. This @@ -2797,8 +2884,8 @@ mod tests { use super::test_support::app_from_fixture; use super::{ - find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, EffectiveZoom, Layout, Role, - Zoom, DEFAULT_OUTLINE_WIDTH, + find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, DiffState, EffectiveZoom, + Layout, Role, Zoom, DEFAULT_OUTLINE_WIDTH, }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::ReviewConfig; @@ -5538,6 +5625,8 @@ mod tests { label: "cs-a".to_string(), current: false, needs_restack: false, + loading: false, + failed: false, } ); let header_b = items @@ -5551,10 +5640,113 @@ mod tests { label: "cs-b".to_string(), current: true, needs_restack: true, + loading: false, + failed: false, } ); } + // ── ADR-031: per-changeset slots (Pending/Ready/Failed) ───────────────────── + + /// A minimal [`Changeset`] descriptor for the slot tests below — the slot model only cares + /// about the metadata `ChangesetView::pending`/`failed` carry alongside a diff-free + /// [`DiffState`], not any real git content. + fn bare_changeset(name: &str, current: bool) -> Changeset { + Changeset { + name: name.to_string(), + span: ChangesetSpan::Uncommitted, + title: None, + current, + needs_restack: false, + } + } + + #[test] + fn app_is_constructible_from_a_pending_changeset_alone() { + // ADR-031: `App::from_changesets`'s >=1 assert survives unchanged — an all-Pending stack + // (the streamed-launch shape, before any diff has landed) is a valid `App`. + let view = ChangesetView::pending(bare_changeset("cs-a", true)); + let fixture = FixtureBuilder::new().build().unwrap(); + let repo = Repository::open(fixture.repo().unwrap().workdir().unwrap()).unwrap(); + let app = App::from_changesets(repo, vec![view]); + + assert!(app.is_current_pending()); + assert_eq!(app.current_failure(), None); + assert!(app.files().is_empty()); + assert_eq!(app.changeset_count(), 1); + } + + #[test] + fn navigating_onto_a_pending_changeset_shows_no_files_and_stays_pending() { + let fixture = two_changes_one_hunk_fixture(); + let repo = fixture.repo().unwrap(); + let cs_ready = bare_changeset("cs-ready", true); + let diffs = crate::acquire::diff_uncommitted(repo).unwrap(); + let view_ready = ChangesetView::new(cs_ready, DiffState::from(diffs)); + let view_pending = ChangesetView::pending(bare_changeset("cs-pending", false)); + + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view_ready, view_pending]); + + assert!(!app.is_current_pending(), "opens on the Ready changeset"); + assert!(!app.files().is_empty()); + + app.next_changeset(); + + assert_eq!(app.current_cs(), 1); + assert!(app.is_current_pending()); + assert!( + app.files().is_empty(), + "a Pending changeset has no file rows to navigate onto" + ); + } + + #[test] + fn failed_slot_carries_its_error_message() { + let view = ChangesetView::failed(bare_changeset("cs-a", true), "diff acquisition failed"); + let fixture = FixtureBuilder::new().build().unwrap(); + let repo = Repository::open(fixture.repo().unwrap().workdir().unwrap()).unwrap(); + let app = App::from_changesets(repo, vec![view]); + + assert!(!app.is_current_pending()); + assert_eq!(app.current_failure(), Some("diff acquisition failed")); + assert!(app.files().is_empty()); + } + + #[test] + fn outline_marks_pending_and_failed_changeset_headers() { + let view_pending = ChangesetView::pending(bare_changeset("cs-pending", true)); + let view_failed = ChangesetView::failed(bare_changeset("cs-failed", false), "boom"); + let fixture = FixtureBuilder::new().build().unwrap(); + let repo = Repository::open(fixture.repo().unwrap().workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(repo, vec![view_pending, view_failed]); + app.outline.mode = OutlineMode::Stack; + + let items = app.outline_items(); + assert_eq!( + items, + vec![ + OutlineItem::Header { + cs_idx: 0, + label: "cs-pending".to_string(), + current: true, + needs_restack: false, + loading: true, + failed: false, + }, + OutlineItem::Header { + cs_idx: 1, + label: "cs-failed".to_string(), + current: false, + needs_restack: false, + loading: false, + failed: true, + }, + ], + "Pending/Failed changesets emit only their (marked) header, no file rows" + ); + } + #[test] fn staged_status_column_only_populated_for_the_uncommitted_changesets_files() { let mut app = committed_and_uncommitted_stack(); diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 27dd12d..f045a03 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -108,6 +108,12 @@ pub struct OutlineChangeset { pub current: bool, /// Mirrors `workon::Changeset::needs_restack` — drives the outline's amber warning glyph. pub needs_restack: bool, + /// ADR-031: the changeset's diff hasn't been acquired yet — the header shows a loading + /// indication in place of the (currently absent, since `files` is empty for a `Pending` + /// slot) file rows. + pub loading: bool, + /// ADR-031: the acquisition attempt for this changeset errored — the header marks it. + pub failed: bool, pub files: Vec, } @@ -132,6 +138,10 @@ pub enum OutlineItem { label: String, current: bool, needs_restack: bool, + /// ADR-031: this changeset hasn't been diffed yet — rendered as a loading indication. + loading: bool, + /// ADR-031: this changeset's acquisition attempt errored — rendered as a marker. + failed: bool, }, /// A directory row — only emitted in [`OutlineMode::Tree`]/[`OutlineMode::StackTree`]. Not a /// jump target: it carries no `cs_idx`/`file_idx`, so `App::outline_move_by` no-ops on it @@ -187,6 +197,8 @@ fn build_stack(changesets: &[OutlineChangeset]) -> Vec { label: cs.label.clone(), current: cs.current, needs_restack: cs.needs_restack, + loading: cs.loading, + failed: cs.failed, }); for (file_idx, file) in cs.files.iter().enumerate() { items.push(OutlineItem::File { @@ -353,6 +365,8 @@ fn build_stack_tree(changesets: &[OutlineChangeset]) -> Vec { label: cs.label.clone(), current: cs.current, needs_restack: cs.needs_restack, + loading: cs.loading, + failed: cs.failed, }); let mut root = TrieNode::default(); for (file_idx, file) in cs.files.iter().enumerate() { @@ -378,6 +392,8 @@ mod tests { label: label.to_string(), current, needs_restack, + loading: false, + failed: false, files: files .iter() .map(|(p, s)| OutlineFile { @@ -388,6 +404,19 @@ mod tests { } } + /// [`cs`] variant for ADR-031's slot tests — builds a `Pending`/`Failed` outline changeset + /// (no files, since a non-`Ready` [`crate::app::ChangesetView`] never has any). + fn cs_slot(label: &str, loading: bool, failed: bool) -> OutlineChangeset { + OutlineChangeset { + label: label.to_string(), + current: false, + needs_restack: false, + loading, + failed, + files: Vec::new(), + } + } + #[test] fn stack_mode_emits_a_header_before_each_changesets_files() { let changesets = vec![ @@ -403,6 +432,8 @@ mod tests { label: "cs-a".to_string(), current: false, needs_restack: false, + loading: false, + failed: false, }, OutlineItem::File { cs_idx: 0, @@ -416,6 +447,8 @@ mod tests { label: "cs-b".to_string(), current: true, needs_restack: true, + loading: false, + failed: false, }, OutlineItem::File { cs_idx: 1, @@ -428,6 +461,40 @@ mod tests { ); } + /// ADR-031: a `Pending`/`Failed` changeset (no files) still emits a Stack-mode header row, + /// carrying the loading/failed marker instead of any file rows. + #[test] + fn stack_mode_marks_pending_and_failed_headers_with_no_file_rows() { + let changesets = vec![ + cs_slot("cs-pending", true, false), + cs_slot("cs-failed", false, true), + ]; + let items = build_items(&changesets, OutlineMode::Stack); + assert_eq!( + items, + vec![ + OutlineItem::Header { + cs_idx: 0, + label: "cs-pending".to_string(), + current: false, + needs_restack: false, + loading: true, + failed: false, + }, + OutlineItem::Header { + cs_idx: 1, + label: "cs-failed".to_string(), + current: false, + needs_restack: false, + loading: false, + failed: true, + }, + ], + "a Pending/Failed changeset carries no file rows (its files list is empty), only its \ + own marked header" + ); + } + #[test] fn flat_mode_has_no_headers() { let changesets = vec![cs( @@ -625,6 +692,8 @@ mod tests { label: "cs-a".to_string(), current: false, needs_restack: false, + loading: false, + failed: false, }, OutlineItem::Dir { name: "x".to_string(), @@ -642,6 +711,8 @@ mod tests { label: "cs-b".to_string(), current: true, needs_restack: true, + loading: false, + failed: false, }, OutlineItem::File { cs_idx: 1, diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index c85bd10..2bf2d88 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -535,6 +535,8 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette) -> Line<'static> { label, current, needs_restack, + loading, + failed, .. } => { let marker = if *current { "\u{25CF} " } else { " " }; @@ -551,6 +553,13 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette) -> Line<'static> { if *needs_restack { spans.push(TSpan::styled(" \u{26A0}", Style::default().fg(FG_WARN))); } + // ADR-031: a Failed changeset's marker wins over Pending's (a slot is never both, + // but Failed is the more actionable state to surface if it somehow were). + if *failed { + spans.push(TSpan::styled(" \u{2717}", Style::default().fg(FG_ERROR))); + } else if *loading { + spans.push(TSpan::styled(" \u{2026}", Style::default().fg(theme.dim))); + } Line::from(spans) } OutlineItem::Dir { name, guides } => { @@ -766,6 +775,25 @@ fn render_loading_placeholder( } fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { + // ADR-031: 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 + // intentionally empty one. + if let Some(message) = app.current_failure() { + let msg = format!("Failed to load this changeset: {message}"); + frame.render_widget( + Paragraph::new(msg).style(Style::default().fg(FG_ERROR)), + area, + ); + return; + } + if app.is_current_pending() { + frame.render_widget( + Paragraph::new("Loading\u{2026}").style(Style::default().fg(theme.dim)), + area, + ); + return; + } if app.files().is_empty() { frame.render_widget(Paragraph::new("(no changes)"), area); return; From 75cd1b50ea77921a16f66f588c2999a87566c06a Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 00:56:27 -0400 Subject: [PATCH 085/203] feat(review): route input through a dedicated thread and mpsc inbox --- git-workon-review/src/tui.rs | 281 +++++++++++++++++++++++++++++------ 1 file changed, 234 insertions(+), 47 deletions(-) diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 07b8c93..53a6a4c 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -1,17 +1,25 @@ //! Terminal lifecycle, event seam, and the main input loop for the review TUI. //! //! Ported loop shape from the `review-tui-spike` prototype's `main.rs` (`install_panic_hook`, -//! raw-mode + alternate-screen setup, `draw -> quit-check -> next_event -> update`), adapted to -//! read events through [`next_event`] rather than calling crossterm directly from the loop. +//! raw-mode + alternate-screen setup, `draw -> quit-check -> recv_event -> update`), adapted to +//! read events through the [`AppEvent`] inbox rather than calling crossterm directly from the +//! loop. //! -//! M4's index watcher (locked decision #4) does NOT swap `next_event`'s internals for a -//! channel-fed watcher thread, despite an earlier note here suggesting that direction — the -//! locked decision is a synchronous poll on the existing `Tick` (every `next_event` timeout), -//! comparing [`workon_review::refresh::IndexSignature`] and re-diffing in place via -//! [`App::on_tick`] when it changes. No threads, no `mpsc`, no new deps. +//! ADR-031 (progressive pipeline) supersedes M4's locked decision #4 — the "no threads, no +//! `mpsc`" letter of that note, recorded here in an earlier revision, no longer holds. A +//! dedicated *input thread* (spawned by [`Tui::run`]) is now the ONLY code that calls +//! crossterm's event API: it blocks on `event::read()` forever, maps each event exactly like +//! this module's old `next_event`/`drain_pending` read arms did, and forwards mapped events into +//! an `std::sync::mpsc` inbox that the main loop drains via [`recv_event`]/[`drain_pending`]. +//! `recv_timeout`'s timeout arm IS the `Tick` beat — unchanged from before, just relocated from +//! `event::poll`'s timeout to the channel's. The M4 index watcher's *semantics* are exactly +//! unchanged by this move: it still compares [`workon_review::refresh::IndexSignature`] and +//! re-diffs in place via [`App::on_tick`] on every `Tick`; only the beat's mechanism moved. use std::fs::File; use std::io::{self, Write}; +use std::sync::mpsc; +use std::thread; use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; @@ -28,51 +36,104 @@ use workon_review::keymap::{Command, Dispatch, KeyPress, Keymap}; use workon_review::render; use workon_review::theme::Palette; -/// One event the review loop reacts to. `Tick` is now also the index-watcher's poll beat (see the -/// module doc's note on locked decision #4) — `next_event`'s mapping and this enum otherwise stay -/// the shape M3 built. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// 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`]). +/// `Key`/`Resize` are forwarded from the input thread via [`map_terminal_event`]. Not `Copy` +/// (ADR-031): the next slice's loader-result variants carry non-`Copy` payloads; dropping `Copy` +/// now is mechanical prep so this slice's diff doesn't collide with that one's. +#[derive(Debug, Clone, PartialEq, Eq)] pub enum AppEvent { Key(KeyEvent), Resize(u16, u16), Tick, } -/// Poll for the next terminal event, up to `timeout`. -/// -/// `Ok(Some(AppEvent::Tick))` on a plain timeout (the loop's regular redraw beat); `Ok(None)` for -/// a terminal event we don't map to an [`AppEvent`] (key release/repeat, mouse, paste, focus) — -/// the loop redraws and keeps going without calling `update`. -pub fn next_event(timeout: Duration) -> io::Result> { - if !event::poll(timeout)? { - return Ok(Some(AppEvent::Tick)); - } - Ok(match event::read()? { +/// The inbox message type: a mapped terminal event, or the input thread's terminal `event::read` +/// error forwarded verbatim (ADR-031: "the input thread never exits silently" — a read error is +/// still observable, just relayed rather than swallowed). `Tick` never appears here. +type InboxMessage = io::Result; + +/// Map one crossterm terminal [`Event`] to the [`AppEvent`] the loop reacts to — key-press and +/// resize map; key release/repeat, mouse, paste, and focus events are skipped (`None`), exactly +/// like this module's pre-ADR-031 `next_event`/`drain_pending` read arms did. Pure and +/// independent of any thread or channel, so it's unit-tested directly; the input thread's loop +/// body is a thin wrapper around it. +fn map_terminal_event(event: Event) -> Option { + match event { Event::Key(key) if key.kind == KeyEventKind::Press => Some(AppEvent::Key(key)), Event::Resize(w, h) => Some(AppEvent::Resize(w, h)), _ => None, - }) + } +} + +/// Spawn the dedicated input thread and return the receiving end of its inbox. Must be called +/// AFTER the terminal is acquired and any pre-takeover tty work (the theme probe, stray-input +/// flush) has finished — crossterm input must not be consumed before that ordering completes +/// (see `main.rs`'s block comment on the resolve/probe/acquire sequence). The thread loops +/// forever on a blocking `event::read()`, forwarding mapped events; on a read error it forwards +/// the error once and exits — the sole way this thread ever stops short of the process dying. +/// Never joined: [`Tui::run`] returns without waiting for it (ADR-031's kill-on-exit lifecycle — +/// the input thread, like the future loader thread, never writes, so an abandoned read can't +/// corrupt anything). +fn spawn_input_thread() -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || loop { + match event::read() { + Ok(event) => { + if let Some(mapped) = map_terminal_event(event) { + if tx.send(Ok(mapped)).is_err() { + return; // main loop is gone; nothing left to forward to + } + } + } + Err(err) => { + let _ = tx.send(Err(err)); + return; + } + } + }); + rx +} + +/// Receive the next event from `inbox`, waiting up to `timeout`. A timeout with nothing received +/// yields `Ok(AppEvent::Tick)` — the loop's regular redraw beat, and the mechanism the M4 index +/// watcher polls on (see the module doc). A disconnected inbox (the input thread panicked, or +/// exited after an error without this being observed yet) is surfaced as an `io::Error` rather +/// than spinning — the loop must exit, not busy-loop on an empty channel forever. +fn recv_event(inbox: &mpsc::Receiver, timeout: Duration) -> io::Result { + match inbox.recv_timeout(timeout) { + Ok(Ok(event)) => Ok(event), + Ok(Err(err)) => Err(err), + Err(mpsc::RecvTimeoutError::Timeout) => Ok(AppEvent::Tick), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(io::Error::other( + "review TUI input thread disconnected without a final error", + )), + } } /// Cap on how many events [`drain_pending`] batches per iteration — leftover input past this -/// count is simply picked up by the next iteration's `next_event` call. +/// count is simply picked up by the next iteration's `recv_event` call. const MAX_DRAIN_BATCH: usize = 128; -/// Drain all immediately-available terminal events into `batch`, mapping them exactly like -/// [`next_event`]'s read arm (key-press and resize map; release/repeat/mouse/paste/focus are -/// skipped, not pushed). Unlike calling `next_event(Duration::ZERO)` in a loop, a not-ready poll -/// here simply stops draining — it must NOT fabricate a `Tick`, since `next_event`'s `!poll` arm -/// exists solely to give the loop its regular redraw beat on a real timeout, and reusing it here -/// would inject a spurious tick at the end of every drain. -fn drain_pending(batch: &mut Vec) -> io::Result<()> { +/// Drain all immediately-available events from `inbox` into `batch`. Unlike calling +/// `recv_event(inbox, Duration::ZERO)` in a loop, an empty inbox here simply stops draining — it +/// must NOT fabricate a `Tick`, since [`recv_event`]'s timeout arm exists solely to give the loop +/// its regular redraw beat on a real timeout, and reusing it here would inject a spurious tick at +/// the end of every drain. +fn drain_pending( + inbox: &mpsc::Receiver, + batch: &mut Vec, +) -> io::Result<()> { while batch.len() < MAX_DRAIN_BATCH { - if !event::poll(Duration::ZERO)? { - break; - } - match event::read()? { - Event::Key(key) if key.kind == KeyEventKind::Press => batch.push(AppEvent::Key(key)), - Event::Resize(w, h) => batch.push(AppEvent::Resize(w, h)), - _ => {} + match inbox.try_recv() { + Ok(Ok(event)) => batch.push(event), + Ok(Err(err)) => return Err(err), + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + return Err(io::Error::other( + "review TUI input thread disconnected without a final error", + )) + } } } Ok(()) @@ -569,8 +630,16 @@ impl Tui { /// `main.rs`'s default) that call marks the open PENDING rather than loading eagerly, so the /// first frame shows CS4's placeholder for one `OPEN_DEBOUNCE` window instead of blocking on /// the initial file's load; a caller that never turned defer mode on gets eager behavior. + /// + /// Spawns the ADR-031 input thread here — after the terminal is fully acquired (`self` already + /// exists, so raw mode and the alternate screen are live) and after every earlier tty + /// consumer (`main.rs`'s theme probe and its stray-input flush) has already run, since those + /// must own the tty before crossterm's event stream has a reader racing them. The thread is + /// never joined: when `run` returns, `main` returns, and the process takes it down (ADR-031's + /// kill-on-exit lifecycle — the input thread never writes, so this can't corrupt anything). pub fn run(&mut self, app: &mut App, keymap: &Keymap, theme: &Palette) -> io::Result<()> { - let result = event_loop(&mut self.terminal, app, keymap, theme); + let inbox = spawn_input_thread(); + let result = event_loop(&mut self.terminal, app, keymap, theme, &inbox); let restored = self.restore(); result.and(restored) } @@ -618,6 +687,7 @@ fn event_loop( app: &mut App, keymap: &Keymap, theme: &Palette, + inbox: &mpsc::Receiver, ) -> io::Result<()> { let mut pending: Vec = Vec::new(); let mut quit = false; @@ -629,9 +699,9 @@ fn event_loop( return Ok(()); } - // While an open is pending, poll on the short debounce window instead of the regular + // While an open is pending, wait on the short debounce window instead of the regular // 200ms redraw beat, so the deferred load runs promptly once input goes quiet — a plain - // timeout (no new terminal event) is what "quiet" means here. This borrows the same + // timeout (no new inbox message) is what "quiet" means here. This borrows the same // `Tick` beat the M4 index watcher already polls on (see the module doc); the watcher // occasionally running ~120ms early during a debounce window is harmless (its own doc // comment already tolerates an "unseen" signature settling one tick late). @@ -641,14 +711,13 @@ fn event_loop( Duration::from_millis(200) }; - if let Some(event) = next_event(timeout)? { - if matches!(event, AppEvent::Tick) && app.open_pending() { - app.complete_pending_open(); - } - let mut batch = vec![event]; - drain_pending(&mut batch)?; - quit = update_batch(app, keymap, &mut pending, batch); + let event = recv_event(inbox, timeout)?; + if matches!(event, AppEvent::Tick) && app.open_pending() { + app.complete_pending_open(); } + let mut batch = vec![event]; + drain_pending(inbox, &mut batch)?; + quit = update_batch(app, keymap, &mut pending, batch); } } @@ -666,6 +735,124 @@ mod tests { KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) } + // ── ADR-031: input thread's pure mapping + inbox draining ────────────────── + + #[test] + fn map_terminal_event_maps_key_press_and_resize() { + assert_eq!( + map_terminal_event(Event::Key(key(KeyCode::Char('q')))), + Some(AppEvent::Key(key(KeyCode::Char('q')))) + ); + assert_eq!( + map_terminal_event(Event::Resize(80, 24)), + Some(AppEvent::Resize(80, 24)) + ); + } + + #[test] + fn map_terminal_event_skips_release_repeat_mouse_paste_and_focus() { + use crossterm::event::{KeyEventState, MouseButton, MouseEvent, MouseEventKind}; + + let release = KeyEvent::new_with_kind( + KeyCode::Char('q'), + KeyModifiers::NONE, + KeyEventKind::Release, + ); + assert_eq!(map_terminal_event(Event::Key(release)), None); + + let repeat = KeyEvent::new_with_kind_and_state( + KeyCode::Char('q'), + KeyModifiers::NONE, + KeyEventKind::Repeat, + KeyEventState::NONE, + ); + assert_eq!(map_terminal_event(Event::Key(repeat)), None); + + assert_eq!( + map_terminal_event(Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })), + None + ); + assert_eq!(map_terminal_event(Event::Paste("pasted".to_string())), None); + assert_eq!(map_terminal_event(Event::FocusGained), None); + assert_eq!(map_terminal_event(Event::FocusLost), None); + let _ = MouseButton::Left; // silence an unused-import lint if MouseButton goes unused above + } + + #[test] + fn recv_event_yields_tick_on_a_plain_timeout() { + let (_tx, rx) = mpsc::channel::(); + let event = recv_event(&rx, Duration::from_millis(5)).expect("timeout is not an error"); + assert_eq!(event, AppEvent::Tick); + } + + #[test] + fn recv_event_forwards_a_sent_event_before_the_timeout() { + let (tx, rx) = mpsc::channel::(); + tx.send(Ok(AppEvent::Key(key(KeyCode::Char('q'))))).unwrap(); + let event = recv_event(&rx, Duration::from_secs(1)).unwrap(); + assert_eq!(event, AppEvent::Key(key(KeyCode::Char('q')))); + } + + #[test] + fn recv_event_propagates_a_forwarded_read_error() { + let (tx, rx) = mpsc::channel::(); + tx.send(Err(io::Error::other("read failed"))).unwrap(); + let err = recv_event(&rx, Duration::from_secs(1)).unwrap_err(); + assert_eq!(err.to_string(), "read failed"); + } + + #[test] + fn recv_event_errors_when_the_inbox_disconnects_instead_of_spinning() { + let (tx, rx) = mpsc::channel::(); + drop(tx); + let result = recv_event(&rx, Duration::from_millis(5)); + assert!( + result.is_err(), + "a disconnected inbox must surface as an error, not a Tick" + ); + } + + #[test] + fn drain_pending_collects_everything_immediately_available_without_a_tick() { + let (tx, rx) = mpsc::channel::(); + tx.send(Ok(AppEvent::Key(key(KeyCode::Char('a'))))).unwrap(); + tx.send(Ok(AppEvent::Key(key(KeyCode::Char('b'))))).unwrap(); + let mut batch = Vec::new(); + drain_pending(&rx, &mut batch).unwrap(); + assert_eq!( + batch, + vec![ + AppEvent::Key(key(KeyCode::Char('a'))), + AppEvent::Key(key(KeyCode::Char('b'))), + ] + ); + } + + #[test] + fn drain_pending_stops_on_an_empty_inbox_without_fabricating_a_tick() { + let (_tx, rx) = mpsc::channel::(); + let mut batch = Vec::new(); + drain_pending(&rx, &mut batch).unwrap(); + assert!( + batch.is_empty(), + "an empty inbox must not inject a spurious Tick" + ); + } + + #[test] + fn drain_pending_propagates_a_forwarded_read_error() { + let (tx, rx) = mpsc::channel::(); + tx.send(Err(io::Error::other("read failed"))).unwrap(); + let mut batch = Vec::new(); + let err = drain_pending(&rx, &mut batch).unwrap_err(); + assert_eq!(err.to_string(), "read failed"); + } + #[test] fn quit_keys_map_to_quit() { let km = Keymap::defaults(); From e92b6d5a0f77e025147cb955e9f2644e17909e96 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 10:01:43 -0400 Subject: [PATCH 086/203] test(review): drop dead MouseButton import in event-mapping test --- git-workon-review/src/tui.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 53a6a4c..697970c 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -751,7 +751,7 @@ mod tests { #[test] fn map_terminal_event_skips_release_repeat_mouse_paste_and_focus() { - use crossterm::event::{KeyEventState, MouseButton, MouseEvent, MouseEventKind}; + use crossterm::event::{KeyEventState, MouseEvent, MouseEventKind}; let release = KeyEvent::new_with_kind( KeyCode::Char('q'), @@ -780,7 +780,6 @@ mod tests { assert_eq!(map_terminal_event(Event::Paste("pasted".to_string())), None); assert_eq!(map_terminal_event(Event::FocusGained), None); assert_eq!(map_terminal_event(Event::FocusLost), None); - let _ = MouseButton::Left; // silence an unused-import lint if MouseButton goes unused above } #[test] From 96fad921e7084ef126f4d0fc5447c1821fc53fb1 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 01:30:57 -0400 Subject: [PATCH 087/203] feat(review): loader thread with generation-tagged file loads --- git-workon-review/src/app.rs | 575 +++++++++++++++++++++++++++++++--- git-workon-review/src/main.rs | 10 +- git-workon-review/src/tui.rs | 399 ++++++++++++++++++----- 3 files changed, 863 insertions(+), 121 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index a744331..dc3956f 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -48,6 +48,7 @@ const SCROLLOFF: usize = 2; /// The new side reads from the **worktree file on disk**, not the index blob — unstaged /// content isn't in the object database; reading the staged (index) blob is an M4 concern (the /// staged/unstaged split zoom). +#[derive(Debug)] pub struct FileView { old_text: String, new_text: String, @@ -346,6 +347,59 @@ fn new_side_tree_for(repo: &Repository, span: ChangesetSpan) -> Option Option { + if file.is_binary { + return None; + } + // Re-peeled per call rather than cached: for the uncommitted layer `HEAD` can move between + // file loads, and the tree is cheap to re-peel either way (see `old_side_tree_for`'s doc + // comment). + let head_tree = old_side_tree_for(repo, span)?; + let new_tree = new_side_tree_for(repo, span); + Some(FileView::load( + repo, + &head_tree, + new_tree.as_ref(), + file, + Role::Combined, + ts, + )) +} + +/// Build a non-Combined ([`Role::Unstaged`]/[`Role::Staged`]) [`FileView`] against `repo`/`ts` for +/// sub-role file `file` — the mirror of [`build_combined_view`], shared the same way. Non-Combined +/// roles are uncommitted-only (a committed changeset's staged/unstaged sub-models are always +/// empty — see [`DiffState::from_committed`]), so the new side always stays worktree/index (`None` +/// to [`FileView::load`]) and the old side is always live `HEAD`, never a changeset's `base`. +/// `None` for a binary file or an unreadable `HEAD`; never panics. +fn build_sub_role_view( + repo: &Repository, + ts: &mut TsHighlighter, + role: Role, + file: &FileChange, +) -> Option { + debug_assert_ne!( + role, + Role::Combined, + "build_sub_role_view is non-Combined only" + ); + if file.is_binary { + return None; + } + let head_tree = repo.head().and_then(|h| h.peel_to_tree()).ok()?; + Some(FileView::load(repo, &head_tree, None, file, role, ts)) +} + fn read_head_blob(repo: &Repository, tree: &git2::Tree<'_>, path: &str) -> String { tree.get_path(Path::new(path)) .and_then(|entry| entry.to_object(repo)) @@ -825,6 +879,19 @@ pub struct App { /// while this is `true`, and the event loop calls [`Self::complete_pending_open`] once input /// has been quiet for `OPEN_DEBOUNCE`. Read via [`Self::open_pending`]. open_pending: bool, + /// Whether a [`crate::app::FileLoadSpec`] has already been dispatched to the ADR-031 loader + /// thread for the CURRENT pending open — set by [`Self::take_pending_load_spec`], cleared + /// whenever a fresh open is marked pending. Without this, every idle `Tick` while + /// `open_pending` stays true (the loader hasn't answered yet) would re-dispatch the same + /// request; this makes dispatch idempotent across the pending open's whole lifetime. + open_pending_dispatched: bool, + /// ADR-031's global generation counter. Invariant: bumps ⟺ every view cache was invalidated + /// — launch seeds it at `1` ([`Self::from_changesets`]); [`Self::refresh`] bumps it on every + /// successful rebuild (still synchronous in this slice). A loader result whose `gen` doesn't + /// match this is for a world that no longer exists and is dropped at the inbox chokepoint + /// ([`Self::apply_file_ready`]) — the ONLY drop rule; within a generation, results are cached + /// even if the user navigated away (warmth, not staleness — see the ADR's "Generations"). + generation: u64, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -962,6 +1029,8 @@ impl App { review_source: None, defer_loads: false, open_pending: false, + open_pending_dispatched: false, + generation: 1, }; // 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 @@ -1041,6 +1110,13 @@ impl App { &self.cur().diff.files } + /// ADR-031's global generation — see the field's doc comment for the invariant. The loader + /// thread stamps every [`FileLoadSpec`] request it's handed with this value at send time; + /// [`Self::apply_file_ready`] drops a result whose stamp no longer matches. + pub fn generation(&self) -> u64 { + self.generation + } + /// Index into the reviewed stack of the active changeset — read by tests asserting the /// [`Self::from_changesets`]/[`Self::refresh`] "honor lib `current`" rule (locked decision /// #6), and by changeset-nav's own tests ([`Self::next_changeset`]/[`Self::prev_changeset`]/ @@ -1185,6 +1261,11 @@ impl App { .unwrap_or_else(|| current_cs_index(&views)); self.base_label = base_label_for(&views[self.current_cs].cs); self.changesets = views; + // ADR-031: every refresh bumps the generation, right where the view caches it protects + // are actually replaced — an early `return` above (a failed resolve/diff) leaves the old + // world's caches intact, so it must NOT bump. Any loader result still in flight for the + // pre-refresh world now carries a stale `gen` and dies at `apply_file_ready`'s chokepoint. + self.generation += 1; let n = self.cur().diff.files.len(); self.current = current_path @@ -1362,62 +1443,26 @@ impl App { Role::Staged => self.cur().diff.staged_model.files[mi].clone(), Role::Combined => unreachable!(), }; - if file.is_binary { + // `file` is cloned out of `self.cur()` (rather than a borrow) because + // `build_sub_role_view` needs `&self.repo` and `&mut self.highlighter` at once, which + // a borrow still anchored in `self.cur()` would conflict with — same rationale as the + // combined path below. + let Some(view) = build_sub_role_view(&self.repo, &mut self.highlighter, role, &file) + else { return; - } - // Build the view in a block so `head_tree` (which borrows `self.repo`) drops before - // the `views_for_mut` reborrow — same reason the combined path below can assign a - // direct field while `head_tree` is live but this method-call path cannot. `file` is - // cloned out of `self.cur()` for the same reason: `FileView::load` needs `&self.repo` - // and `&mut self.highlighter` at once, which a borrow still anchored in `self.cur()` - // would conflict with. - let view = { - // Re-peeled per call, same rationale as the combined path below. - let Ok(head_tree) = self.repo.head().and_then(|h| h.peel_to_tree()) else { - return; - }; - // Non-Combined roles are uncommitted-only (committed changesets have empty - // staged/unstaged sub-models), so the new side always stays worktree/index — - // `None` here preserves that exactly. - FileView::load( - &self.repo, - &head_tree, - None, - &file, - role, - &mut self.highlighter, - ) }; self.views_for_mut(role)[idx] = Some(view); return; } - // Combined role. - // Re-peeled per call rather than cached on `App`: for the uncommitted layer `HEAD` can - // move between file loads, and the tree is cheap to re-peel either way. - // `self.cur().cs.span` is `Copy`, so reading it here borrows `self` only for this - // sub-expression — `head_tree` itself ends up borrowing `self.repo` alone (via the free - // `old_side_tree_for`), leaving `&mut self.highlighter` free below. A method tied to - // `&self` would instead have bound the tree's lifetime to all of `self`. - let Some(head_tree) = old_side_tree_for(&self.repo, self.cur().cs.span) else { + // Combined role. `self.cur().cs.span`/`self.cur().diff.files[idx].clone()` are read out + // (rather than borrowed) for the same reason as the sub-role branch above — + // `build_combined_view` needs `&self.repo` and `&mut self.highlighter` together. + let span = self.cur().cs.span; + let file = self.cur().diff.files[idx].clone(); + let Some(view) = build_combined_view(&self.repo, &mut self.highlighter, span, &file) else { return; }; - // New-side source mirrors the old side: `None` (worktree) for the uncommitted layer, - // the changeset's `head` tree for a committed changeset. Same free-fn borrow dance as - // `old_side_tree_for` — both trees borrow only `self.repo`, so `&mut self.highlighter` - // stays free for `FileView::load`. - let new_tree = new_side_tree_for(&self.repo, self.cur().cs.span); - let file = self.cur().diff.files[idx].clone(); - let view = FileView::load( - &self.repo, - &head_tree, - new_tree.as_ref(), - &file, - Role::Combined, - &mut self.highlighter, - ); - drop(head_tree); - drop(new_tree); self.cur_mut().views_combined[idx] = Some(view); } @@ -1510,6 +1555,9 @@ impl App { pub fn open_current(&mut self) { if self.defer_loads && !self.current_views_cached() { self.open_pending = true; + // A fresh pending open has nothing dispatched to the loader yet — see + // [`Self::take_pending_load_spec`]. + self.open_pending_dispatched = false; self.reset_panes(); return; } @@ -1550,6 +1598,104 @@ impl App { self.ensure_loaded(self.current); self.reset_panes(); self.open_pending = false; + self.open_pending_dispatched = false; + } + + // ── ADR-031: the loader thread's request/result seam ──────────────────────── + + /// Snapshot everything the ADR-031 loader needs to load the CURRENT file, mirroring exactly + /// what [`Self::ensure_loaded`] would read from live state — see [`FileLoadSpec`]'s doc + /// comment. Every field is owned (cloned out), so the spec outlives the borrow and can cross + /// to the loader thread. + fn current_load_spec(&self) -> FileLoadSpec { + let idx = self.current; + let zoom = self.effective_zoom_for(idx); + let diff = &self.cur().diff; + FileLoadSpec { + span: self.cur().cs.span, + combined_file: diff.files[idx].clone(), + zoom, + unstaged_file: diff + .unstaged_idx + .get(idx) + .copied() + .flatten() + .map(|mi| diff.unstaged_model.files[mi].clone()), + staged_file: diff + .staged_idx + .get(idx) + .copied() + .flatten() + .map(|mi| diff.staged_model.files[mi].clone()), + } + } + + /// Take the [`FileLoadSpec`] for the current pending open, tagged with the generation/ + /// changeset/file it was built against — but ONLY if a request hasn't already been dispatched + /// for this same pending open (see [`Self::open_pending_dispatched`]'s doc comment). The event + /// loop calls this on every idle `Tick` while an open is pending; without the dispatched guard + /// it would re-send the same request on every one of those ticks until the loader answers. + /// Returns `None` when nothing is pending, or a request already went out for it. + pub fn take_pending_load_spec(&mut self) -> Option<(u64, usize, usize, FileLoadSpec)> { + if !self.open_pending || self.open_pending_dispatched { + return None; + } + self.open_pending_dispatched = true; + Some(( + self.generation, + self.current_cs, + self.current, + self.current_load_spec(), + )) + } + + /// Apply one loader result (ADR-031'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: + /// + /// - `Ok(views)` caches every view the result carries, UNLESS that slot is already `Some` — a + /// result for an already-cached file is discarded (the loader is a pure cache-warmer, never + /// an overwriter; the synchronous force-completion fallback may have already filled it). + /// - `Err(message)` is a job that panicked or otherwise failed: surfaced as a visible footer + /// notice (never silently stranding the file — see this changeset's report for why a footer + /// notice, not a new per-file `Failed` state, is the shape chosen here). + /// + /// Either way, when the readied file IS the current pending open, it's seated exactly like + /// [`Self::complete_pending_open`]'s tail: `open_pending` clears regardless of `Ok`/`Err` — a + /// failed load must not leave the placeholder stuck forever. Correctness never depends on + /// this: the next nav/force-completion retries via [`Self::ensure_loaded`]'s ordinary + /// cache-miss path, which is where a load actually being CORRECT is guaranteed. + pub fn apply_file_ready( + &mut self, + gen: u64, + cs_idx: usize, + file_idx: usize, + result: Result, + ) { + if gen != self.generation { + return; + } + match result { + Ok(views) => { + if let Some(cs) = self.changesets.get_mut(cs_idx) { + match views { + LoadedViews::Single(role, view) => set_if_absent(cs, role, file_idx, view), + LoadedViews::Split { unstaged, staged } => { + set_if_absent(cs, Role::Unstaged, file_idx, unstaged); + set_if_absent(cs, Role::Staged, file_idx, staged); + } + } + } + } + Err(message) => { + self.notify(format!("failed to load file: {message}"), Severity::Error); + } + } + if cs_idx == self.current_cs && file_idx == self.current && self.open_pending { + self.reset_panes(); + self.open_pending = false; + self.open_pending_dispatched = false; + } } /// Cycle the requested zoom `Split → Combined → Unstaged → Staged → Split` (`z`). The new zoom @@ -2723,6 +2869,103 @@ fn current_cs_index(changesets: &[ChangesetView]) -> usize { changesets.iter().position(|v| v.cs.current).unwrap_or(0) } +// ── ADR-031: the loader thread's stateless request/job shape ──────────────────── + +/// Everything the ADR-031 loader job needs to reproduce one file's [`App::ensure_loaded`] work +/// against its OWN `Repository` + [`TsHighlighter`] — the loader is stateless between jobs (see +/// the ADR's "Protocol": "each request carries what it needs"). Built by +/// [`App::current_load_spec`] from live `App` state at request-send time; every field is owned +/// (cloned out of `App`), so the spec outlives the borrow and crosses to the loader thread. +#[derive(Debug, Clone)] +pub struct FileLoadSpec { + span: ChangesetSpan, + combined_file: FileChange, + /// The [`EffectiveZoom`] `App` had AT DISPATCH TIME — the views built are shaped by this, + /// not by whatever `App`'s zoom/current file happen to be when the result lands (which may + /// have changed by then; that's fine, see the ADR's "Generations": within a generation, a + /// result is warmth even after the user navigated away). + zoom: EffectiveZoom, + unstaged_file: Option, + staged_file: Option, +} + +/// The [`FileView`]s [`build_file_views`] built for one [`FileLoadSpec`], shaped exactly like the +/// [`EffectiveZoom`] it was built for — [`App::apply_file_ready`] reads this shape to know which +/// cache slot(s) to fill without re-deriving the zoom itself (which could disagree with the zoom +/// the views were actually built against — see [`FileLoadSpec::zoom`]'s doc comment). +/// `FileView` fields (`Box`ed here, see below) — a `FileReady` `AppEvent` carrying this unboxed +/// would otherwise make the WHOLE `AppEvent` enum balloon to `FileView`'s size on every variant +/// (clippy's `large_enum_variant`), even the plain `Key`/`Tick` ones sent on every keystroke. +#[derive(Debug)] +pub enum LoadedViews { + Single(Role, Option>), + Split { + unstaged: Option>, + staged: Option>, + }, +} + +/// Build every [`FileView`] a [`FileLoadSpec`] needs, against `repo`/`ts` — the ADR-031 loader +/// thread's pure job body: unit-testable directly against a fixture repo, no threads or channels +/// involved. Routes through the SAME [`build_combined_view`]/[`build_sub_role_view`] free +/// functions [`App::ensure_role_loaded`] calls, so a deferred-then-loader-completed open is +/// byte-identical to an eager [`App::open_current`] — the invariant ADR-031 carries over from +/// CS4's `complete_pending_open`. +pub fn build_file_views( + repo: &Repository, + ts: &mut TsHighlighter, + spec: &FileLoadSpec, +) -> LoadedViews { + match spec.zoom { + EffectiveZoom::Single(role) => { + let view = match role { + Role::Combined => build_combined_view(repo, ts, spec.span, &spec.combined_file), + Role::Unstaged => spec + .unstaged_file + .as_ref() + .and_then(|f| build_sub_role_view(repo, ts, Role::Unstaged, f)), + Role::Staged => spec + .staged_file + .as_ref() + .and_then(|f| build_sub_role_view(repo, ts, Role::Staged, f)), + }; + LoadedViews::Single(role, view.map(Box::new)) + } + EffectiveZoom::Split => LoadedViews::Split { + unstaged: spec + .unstaged_file + .as_ref() + .and_then(|f| build_sub_role_view(repo, ts, Role::Unstaged, f)) + .map(Box::new), + staged: spec + .staged_file + .as_ref() + .and_then(|f| build_sub_role_view(repo, ts, Role::Staged, f)) + .map(Box::new), + }, + } +} + +/// Cache `view` into changeset `cs`'s `role` view slot for file `idx`, UNLESS that slot is +/// already `Some` — [`App::apply_file_ready`]'s "a result for an already-cached file is +/// discarded" rule (the loader is a pure cache-warmer, never an overwriter). A no-op if `idx` is +/// out of range (the changeset shrank across a refresh — should already be unreachable, since a +/// refresh bumps the generation and `apply_file_ready` drops stale-generation results before +/// this ever runs, but `get_mut` stays defensive rather than indexing). +fn set_if_absent(cs: &mut ChangesetView, role: Role, idx: usize, view: Option>) { + let view = view.map(|boxed| *boxed); + let slots = match role { + Role::Combined => &mut cs.views_combined, + Role::Unstaged => &mut cs.views_unstaged, + Role::Staged => &mut cs.views_staged, + }; + if let Some(slot) = slots.get_mut(idx) { + if slot.is_none() { + *slot = view; + } + } +} + /// [`App::base_label`] for the changeset that would become active — a committed changeset's /// base rev (7-char short-sha), or `"HEAD"` for the uncommitted layer (worktree ↔ `HEAD`, /// unchanged from M2–M4). @@ -2884,8 +3127,8 @@ mod tests { use super::test_support::app_from_fixture; use super::{ - find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, DiffState, EffectiveZoom, - Layout, Role, Zoom, DEFAULT_OUTLINE_WIDTH, + build_file_views, find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, DiffState, + EffectiveZoom, Layout, LoadedViews, Role, Severity, Zoom, DEFAULT_OUTLINE_WIDTH, }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::ReviewConfig; @@ -3103,6 +3346,238 @@ mod tests { assert_eq!(app.scroll, scroll_before); } + // ── ADR-031: the loader's request/result seam ──────────────────────────────── + + #[test] + fn build_file_views_matches_ensure_loaded_for_the_combined_role() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("tracked.txt", "line1\nline2\n", "line1\nCHANGED\n") + .build() + .unwrap(); + + let mut eager = app_from_fixture(&fixture); + // The file only has an unstaged change, so the default `Split` zoom would collapse to + // `Role::Unstaged` — force `Combined` explicitly so this test exercises the role its + // name promises (a separate test would be needed for the Split/sub-role shape). + eager.set_zoom(Zoom::Combined); + eager.ensure_loaded(0); + let eager_view = eager.current_view_ref().expect("eager view loaded"); + + // A SEPARATE `App` gives us `current_load_spec()` for the same file, and a SEPARATE + // `Repository` handle + fresh `TsHighlighter` stands in for the loader thread's own — + // exactly the two-handle shape `Tui::run`/`spawn_loader_thread` build for real. + let mut spec_app = app_from_fixture(&fixture); + spec_app.set_zoom(Zoom::Combined); + let spec = spec_app.current_load_spec(); + let repo = fixture.repo().unwrap(); + let loader_repo = + Repository::open(repo.workdir().unwrap()).expect("loader's own repo handle"); + let mut loader_ts = crate::highlight::TsHighlighter::new(); + let views = build_file_views(&loader_repo, &mut loader_ts, &spec); + + let LoadedViews::Single(role, Some(loader_view)) = views else { + panic!("expected a loaded Combined-role view"); + }; + assert_eq!(role, Role::Combined); + assert_eq!(loader_view.old_text(), eager_view.old_text()); + assert_eq!(loader_view.new_text(), eager_view.new_text()); + assert_eq!(loader_view.display.len(), eager_view.display.len()); + } + + #[test] + fn apply_file_ready_completes_a_pending_open_byte_identical_to_eager_open() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "tracked.txt", + "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nold\nl10\nl11\nl12\n", + "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nnew\nl10\nl11\nl12\n", + ) + .build() + .unwrap(); + + let (mut deferred, eager) = defer_and_eager_twins(&fixture); + assert!(deferred.open_pending()); + + // The ASYNC path: take the pending spec (as `tui.rs`'s event loop would on the debounce + // `Tick`), build its views through a SEPARATE repo/highlighter (standing in for the + // loader thread's own), then apply the result — never `complete_pending_open`. + let (gen, cs_idx, file_idx, spec) = deferred + .take_pending_load_spec() + .expect("a fresh pending open has an undispatched spec"); + let repo = fixture.repo().unwrap(); + let loader_repo = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut loader_ts = crate::highlight::TsHighlighter::new(); + let views = build_file_views(&loader_repo, &mut loader_ts, &spec); + + deferred.apply_file_ready(gen, cs_idx, file_idx, Ok(views)); + + assert!( + !deferred.open_pending(), + "apply_file_ready must clear the pending flag for the file it just seated" + ); + assert_eq!( + deferred.cursor, eager.cursor, + "cursor must land on the same (first-hunk) row an eager open would have" + ); + assert_eq!(deferred.scroll, eager.scroll); + let deferred_view = deferred.current_view_ref().expect("view now loaded"); + let eager_view = eager.current_view_ref().expect("eager view loaded"); + assert_eq!(deferred_view.old_text(), eager_view.old_text()); + assert_eq!(deferred_view.new_text(), eager_view.new_text()); + assert_eq!(deferred_view.display.len(), eager_view.display.len()); + } + + #[test] + fn take_pending_load_spec_dispatches_at_most_once_per_pending_open() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.set_defer_loads(true); + app.open_current(); + assert!( + app.take_pending_load_spec().is_some(), + "first take dispatches" + ); + assert!( + app.take_pending_load_spec().is_none(), + "a second take before the result lands must not re-dispatch" + ); + } + + #[test] + fn apply_file_ready_drops_a_stale_generation_result() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.set_defer_loads(true); + app.open_current(); + let (gen, cs_idx, file_idx, spec) = app.take_pending_load_spec().unwrap(); + + let repo = fixture.repo().unwrap(); + let loader_repo = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut loader_ts = crate::highlight::TsHighlighter::new(); + let views = build_file_views(&loader_repo, &mut loader_ts, &spec); + + // A refresh between dispatch and result bumps the generation — the result now belongs + // to a world that no longer exists and must be dropped outright, leaving `open_pending` + // untouched (a FRESH open may since be pending for a different generation). + app.generation += 1; + app.apply_file_ready(gen, cs_idx, file_idx, Ok(views)); + + assert!( + app.open_pending(), + "a stale-generation result must not clear a (possibly fresh) pending open" + ); + assert!( + app.current_view_ref().is_none(), + "a stale-generation result must not populate the view cache" + ); + } + + #[test] + fn apply_file_ready_caches_a_result_even_after_navigating_away() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .unstaged_file("b.txt", "two\n", "two\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.set_zoom(Zoom::Combined); + app.set_defer_loads(true); + app.open_current(); // a.txt: uncached — defers + let (gen, cs_idx, file_idx, spec) = app.take_pending_load_spec().unwrap(); + assert_eq!(file_idx, 0); + + // Navigate away from a.txt BEFORE the (simulated) loader result lands. + app.current = 1; + app.open_current(); + + let repo = fixture.repo().unwrap(); + let loader_repo = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut loader_ts = crate::highlight::TsHighlighter::new(); + let views = build_file_views(&loader_repo, &mut loader_ts, &spec); + app.apply_file_ready(gen, cs_idx, file_idx, Ok(views)); + + // Still within the same generation — the result is warmth, not staleness: a.txt's cache + // is populated even though the user is no longer looking at it. + assert!( + app.role_view_ref(0, Role::Combined).is_some(), + "a within-generation result must cache even after the user navigated away" + ); + } + + #[test] + fn apply_file_ready_discards_a_result_for_an_already_cached_file() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.set_zoom(Zoom::Combined); + app.ensure_loaded(0); // eagerly cached already + assert!(app.role_view_ref(0, Role::Combined).is_some()); + let old_text_before = app + .role_view_ref(0, Role::Combined) + .unwrap() + .old_text() + .to_string(); + + // A result claiming NOTHING loaded for this role (e.g. a stale/racing answer) must not + // clobber the already-cached view — the loader is a pure cache-warmer, never an + // overwriter. + app.apply_file_ready( + app.generation(), + app.current_cs(), + 0, + Ok(LoadedViews::Single(Role::Combined, None)), + ); + + let view = app.role_view_ref(0, Role::Combined).expect("still cached"); + assert_eq!(view.old_text(), old_text_before); + } + + #[test] + fn apply_file_ready_err_surfaces_a_footer_notice_and_clears_pending() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.set_defer_loads(true); + app.open_current(); + let (gen, cs_idx, file_idx, _spec) = app.take_pending_load_spec().unwrap(); + assert!(app.notice.is_none()); + + app.apply_file_ready(gen, cs_idx, file_idx, Err("boom".to_string())); + + assert!( + !app.open_pending(), + "a failed load must not strand the placeholder pending forever" + ); + let notice = app + .notice + .as_ref() + .expect("a failed load surfaces a notice"); + assert_eq!(notice.severity, Severity::Error); + assert!(notice.text.contains("boom")); + } + // Hunk-nav helpers below operate purely over `DisplayRow` vectors — no fixture repo needed. fn ctx_row(n: usize) -> DisplayRow { diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 50cf250..9ca9d73 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -164,6 +164,14 @@ fn main() -> Result<()> { return Ok(()); } + // Captured BEFORE `repo` moves into `App` below — the ADR-031 loader thread needs its own + // `Repository` handle onto the same on-disk repo (`git2::Repository` is `Send` but not + // `Sync`, so it can't cross threads directly), opened the same way + // `crate::acquire::diff_changesets`'s worker threads already do: at the workdir so the + // uncommitted layer's index/worktree diffs resolve correctly, falling back to the gitdir for + // a bare repo (where only committed spans can occur). + let repo_path = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf(); + // `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). @@ -195,7 +203,7 @@ fn main() -> Result<()> { // A carried acquire failure surfaces HERE — the same logical point (running the TUI) it // surfaced at before CS5 moved the terminal takeover ahead of the diff phase. tui.into_diagnostic()? - .run(&mut app, &keymap, &theme) + .run(&mut app, &keymap, &theme, repo_path) .into_diagnostic()?; Ok(()) diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 697970c..35dc7a7 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -18,6 +18,7 @@ use std::fs::File; use std::io::{self, Write}; +use std::path::PathBuf; use std::sync::mpsc; use std::thread; use std::time::Duration; @@ -27,25 +28,56 @@ use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; +use git2::Repository; use ratatui::backend::CrosstermBackend; use ratatui::style::{Modifier, Style}; use ratatui::widgets::Paragraph; use ratatui::{Frame, Terminal}; -use workon_review::app::App; +use workon_review::app::{self, App, FileLoadSpec, LoadedViews}; +use workon_review::highlight::TsHighlighter; use workon_review::keymap::{Command, Dispatch, KeyPress, Keymap}; use workon_review::render; use workon_review::theme::Palette; /// 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`]). -/// `Key`/`Resize` are forwarded from the input thread via [`map_terminal_event`]. Not `Copy` -/// (ADR-031): the next slice's loader-result variants carry non-`Copy` payloads; dropping `Copy` -/// now is mechanical prep so this slice's diff doesn't collide with that one's. -#[derive(Debug, Clone, PartialEq, Eq)] +/// `Key`/`Resize` are forwarded from the input thread via [`map_terminal_event`]; `FileReady` is +/// forwarded from the loader thread via [`run_load_job`]. Not `Copy`/`Clone`/`PartialEq`/`Eq` +/// (ADR-031): `FileReady`'s payload carries [`LoadedViews`], which wraps +/// [`workon_review::app::FileView`] — a type with none of those (its highlight/word-diff caches +/// don't implement them, and rebuilding one is cheap enough that nothing has ever needed to). +#[derive(Debug)] pub enum AppEvent { Key(KeyEvent), Resize(u16, u16), Tick, + /// One [`LoadRequest`]'s result — ADR-031's loader-result variant. `gen`/`cs_idx`/`file_idx` + /// echo the request's stamp; `result` is `Err` for a job that panicked or otherwise failed + /// (see [`run_load_job`]'s doc comment for why a footer notice, not a new AppEvent shape, is + /// how that surfaces). Applied at ONE chokepoint: [`App::apply_file_ready`]. + FileReady { + gen: u64, + cs_idx: usize, + file_idx: usize, + result: Result, + }, +} + +impl PartialEq for AppEvent { + /// Manual, deliberately PARTIAL equality (can't derive — `FileReady`'s `LoadedViews` payload + /// isn't `PartialEq`, see the enum's doc comment): `Key`/`Resize`/`Tick` compare structurally, + /// exactly like the pre-ADR-031 derive did, for the input-thread tests that still assert + /// mapped-event shape via `assert_eq!`. Two `FileReady` events are never considered equal — + /// there's no sound definition of "the same loader result" once `FileView` can't be compared, + /// and nothing needs one; tests that care about a `FileReady`'s fields match on them directly. + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (AppEvent::Key(a), AppEvent::Key(b)) => a == b, + (AppEvent::Resize(w1, h1), AppEvent::Resize(w2, h2)) => w1 == w2 && h1 == h2, + (AppEvent::Tick, AppEvent::Tick) => true, + _ => false, + } + } } /// The inbox message type: a mapped terminal event, or the input thread's terminal `event::read` @@ -66,17 +98,19 @@ fn map_terminal_event(event: Event) -> Option { } } -/// Spawn the dedicated input thread and return the receiving end of its inbox. Must be called -/// AFTER the terminal is acquired and any pre-takeover tty work (the theme probe, stray-input -/// flush) has finished — crossterm input must not be consumed before that ordering completes -/// (see `main.rs`'s block comment on the resolve/probe/acquire sequence). The thread loops -/// forever on a blocking `event::read()`, forwarding mapped events; on a read error it forwards -/// the error once and exits — the sole way this thread ever stops short of the process dying. -/// Never joined: [`Tui::run`] returns without waiting for it (ADR-031's kill-on-exit lifecycle — -/// the input thread, like the future loader thread, never writes, so an abandoned read can't -/// corrupt anything). -fn spawn_input_thread() -> mpsc::Receiver { - let (tx, rx) = mpsc::channel(); +/// Spawn the dedicated input thread against an already-built inbox sender. Must be called AFTER +/// the terminal is acquired and any pre-takeover tty work (the theme probe, stray-input flush) +/// has finished — crossterm input must not be consumed before that ordering completes (see +/// `main.rs`'s block comment on the resolve/probe/acquire sequence). The thread loops forever on +/// a blocking `event::read()`, forwarding mapped events; on a read error it forwards the error +/// once and exits — the sole way this thread ever stops short of the process dying. Never joined: +/// [`Tui::run`] returns without waiting for it (ADR-031's kill-on-exit lifecycle — the input +/// thread, like the loader thread, never writes, so an abandoned read can't corrupt anything). +/// +/// `tx` is a clone of the SAME inbox sender the loader thread also holds (ADR-031's "one inbox" — +/// [`Tui::run`] builds the channel once and hands a clone to each producer thread), so both +/// threads' events interleave into a single `recv_event`/`drain_pending` stream. +fn spawn_input_thread(tx: mpsc::Sender) { thread::spawn(move || loop { match event::read() { Ok(event) => { @@ -92,7 +126,94 @@ fn spawn_input_thread() -> mpsc::Receiver { } } }); - rx +} + +/// One file-load request handed to the loader thread (ADR-031's "Protocol": the loader is +/// stateless between jobs — everything a job needs rides along on the request). `gen`/`cs_idx`/ +/// `file_idx` are stamped at send time from [`App::take_pending_load_spec`]'s return and echoed +/// back verbatim on the [`AppEvent::FileReady`] result, so [`App::apply_file_ready`] can apply +/// (or drop) it without the loader ever touching `App`. +struct LoadRequest { + gen: u64, + cs_idx: usize, + file_idx: usize, + spec: FileLoadSpec, +} + +/// Extract a human-readable message from a `catch_unwind` panic payload — the common `&str`/ +/// `String` panic-message shapes get their text; anything else (a panic with a non-string +/// payload) falls back to a generic message rather than failing to report at all. +fn panic_message(payload: Box) -> String { + if let Some(s) = payload.downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "loader job panicked".to_string() + } +} + +/// The ADR-031 loader job's pure body: `LoadRequest -> AppEvent`, unit-tested directly (no +/// threads) against a fixture repo + highlighter. Wrapped in `catch_unwind` per the ADR's +/// "Lifecycle" decision — the specific failure mode this catches that nothing else does: a +/// panicked job would otherwise silently drop into a slot stranded `Pending` forever (the file +/// never re-requested, since [`App::open_pending_dispatched`]'s guard already marked it sent), an +/// invisible hang instead of a visible error. +/// +/// A panic's message surfaces through [`AppEvent::FileReady`]'s `Err` arm, which +/// [`App::apply_file_ready`] turns into a footer notice — a footer notice, not a new per-file +/// `Failed` slot, is the shape chosen here (see this changeset's report): it's visible, it +/// doesn't strand `open_pending`, and correctness never depended on the loader succeeding in the +/// first place (the force-completion sync fallback is where correctness actually lives). +fn run_load_job(repo: &Repository, ts: &mut TsHighlighter, req: LoadRequest) -> AppEvent { + let LoadRequest { + gen, + cs_idx, + file_idx, + spec, + } = req; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + app::build_file_views(repo, ts, &spec) + })) + .map_err(panic_message); + AppEvent::FileReady { + gen, + cs_idx, + file_idx, + result, + } +} + +/// Spawn the ADR-031 loader thread: it owns its own long-lived `Repository` + `TsHighlighter` +/// (never `App`'s — the loader is a separate thread and can't touch `App`'s handles) and serves +/// [`LoadRequest`]s sequentially off `req_rx`, forwarding each job's [`AppEvent::FileReady`] into +/// the shared inbox via `tx` (a clone of the same sender the input thread holds). Returns the +/// `Sender` half the main loop dispatches requests through. +/// +/// If `repo_path` can't be opened here (should be unreachable — `main.rs` already opened it once +/// to build `App`), the thread exits immediately without serving anything: every subsequent +/// dispatch attempt just accumulates in `req_rx`'s buffer until the main loop's `send` starts +/// erroring, which is harmless (the force-completion sync fallback is what correctness actually +/// depends on — see [`run_load_job`]'s doc comment). Never joined — same kill-on-exit lifecycle as +/// the input thread. +fn spawn_loader_thread( + repo_path: PathBuf, + tx: mpsc::Sender, +) -> mpsc::Sender { + let (req_tx, req_rx) = mpsc::channel::(); + thread::spawn(move || { + let Ok(repo) = Repository::open(&repo_path) else { + return; + }; + let mut ts = TsHighlighter::new(); + for req in req_rx { + let event = run_load_job(&repo, &mut ts, req); + if tx.send(Ok(event)).is_err() { + return; // main loop is gone; nothing left to forward to + } + } + }); + req_tx } /// Receive the next event from `inbox`, waiting up to `timeout`. A timeout with nothing received @@ -417,6 +538,15 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap false } AppEvent::Resize(_, _) => false, + AppEvent::FileReady { + gen, + cs_idx, + file_idx, + result, + } => { + app.apply_file_ready(gen, cs_idx, file_idx, result); + false + } } } @@ -628,18 +758,33 @@ impl Tui { /// Run the main loop against `app`, then restore the terminal. Callers must have already /// called `app.open_current()` — under CS4's deferred-load mode (`app.set_defer_loads(true)`, /// `main.rs`'s default) that call marks the open PENDING rather than loading eagerly, so the - /// first frame shows CS4's placeholder for one `OPEN_DEBOUNCE` window instead of blocking on - /// the initial file's load; a caller that never turned defer mode on gets eager behavior. + /// first frame shows CS4's placeholder until the ADR-031 loader thread answers (or a + /// force-completion chokepoint loads it synchronously first); a caller that never turned defer + /// mode on gets eager behavior. + /// + /// `repo_path` opens the loader thread's OWN `Repository` handle — a second handle onto the + /// same on-disk repo `app` already holds one of, exactly like `crate::acquire::diff_changesets`'s + /// worker threads (`app` can't hand its handle across threads: `git2::Repository` is `Send` + /// but not `Sync`). /// - /// Spawns the ADR-031 input thread here — after the terminal is fully acquired (`self` already - /// exists, so raw mode and the alternate screen are live) and after every earlier tty - /// consumer (`main.rs`'s theme probe and its stray-input flush) has already run, since those - /// must own the tty before crossterm's event stream has a reader racing them. The thread is - /// never joined: when `run` returns, `main` returns, and the process takes it down (ADR-031's - /// kill-on-exit lifecycle — the input thread never writes, so this can't corrupt anything). - pub fn run(&mut self, app: &mut App, keymap: &Keymap, theme: &Palette) -> io::Result<()> { - let inbox = spawn_input_thread(); - let result = event_loop(&mut self.terminal, app, keymap, theme, &inbox); + /// Builds the single ADR-031 inbox HERE and spawns both the input thread and the loader thread + /// against clones of its sender — after the terminal is fully acquired (`self` already exists, + /// so raw mode and the alternate screen are live) and after every earlier tty consumer + /// (`main.rs`'s theme probe and its stray-input flush) has already run, since those must own + /// 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-031's kill-on-exit + /// lifecycle — neither thread ever writes, so an abandoned one can't corrupt anything). + pub fn run( + &mut self, + app: &mut App, + keymap: &Keymap, + theme: &Palette, + repo_path: PathBuf, + ) -> io::Result<()> { + let (tx, rx) = mpsc::channel::(); + spawn_input_thread(tx.clone()); + let load_tx = spawn_loader_thread(repo_path, tx); + let result = event_loop(&mut self.terminal, app, keymap, theme, &rx, &load_tx); let restored = self.restore(); result.and(restored) } @@ -688,6 +833,7 @@ fn event_loop( keymap: &Keymap, theme: &Palette, inbox: &mpsc::Receiver, + load_tx: &mpsc::Sender, ) -> io::Result<()> { let mut pending: Vec = Vec::new(); let mut quit = false; @@ -700,11 +846,11 @@ fn event_loop( } // While an open is pending, wait on the short debounce window instead of the regular - // 200ms redraw beat, so the deferred load runs promptly once input goes quiet — a plain - // timeout (no new inbox message) is what "quiet" means here. This borrows the same - // `Tick` beat the M4 index watcher already polls on (see the module doc); the watcher - // occasionally running ~120ms early during a debounce window is harmless (its own doc - // comment already tolerates an "unseen" signature settling one tick late). + // 200ms redraw beat, so the deferred load's request goes out promptly once input goes + // quiet — a plain timeout (no new inbox message) is what "quiet" means here. This borrows + // the same `Tick` beat the M4 index watcher already polls on (see the module doc); the + // watcher occasionally running ~120ms early during a debounce window is harmless (its own + // doc comment already tolerates an "unseen" signature settling one tick late). let timeout = if app.open_pending() { OPEN_DEBOUNCE } else { @@ -712,8 +858,21 @@ fn event_loop( }; let event = recv_event(inbox, timeout)?; + // ADR-031: the debounce-fired deferred open is now an ASYNC `LoadFile` request rather + // than a synchronous `complete_pending_open` — the placeholder keeps rendering until the + // loader's `FileReady` result lands (or a force-completion chokepoint loads it + // synchronously first, e.g. the user presses `j` before the loader answers). + // `take_pending_load_spec` is idempotent across repeated debounce-window Ticks: it + // returns `None` once a request has already gone out for the current pending open. if matches!(event, AppEvent::Tick) && app.open_pending() { - app.complete_pending_open(); + if let Some((gen, cs_idx, file_idx, spec)) = app.take_pending_load_spec() { + let _ = load_tx.send(LoadRequest { + gen, + cs_idx, + file_idx, + spec, + }); + } } let mut batch = vec![event]; drain_pending(inbox, &mut batch)?; @@ -782,11 +941,20 @@ mod tests { assert_eq!(map_terminal_event(Event::FocusLost), None); } + /// `AppEvent` dropped `PartialEq`/`Eq` in ADR-031 (`FileReady`'s `LoadedViews` payload wraps + /// `FileView`, which has neither) — this test-only helper is the `matches!`-based replacement + /// for the `assert_eq!(event, AppEvent::Key(key(...)))` shape used throughout this module's + /// tests. Only compares `code`/`modifiers`/`kind` (what `key(...)`/`ctrl_key(...)` set), same + /// fields a `PartialEq` derive on `KeyEvent` itself would have compared. + fn is_key_event(event: &AppEvent, expected: KeyEvent) -> bool { + matches!(event, AppEvent::Key(k) if *k == expected) + } + #[test] fn recv_event_yields_tick_on_a_plain_timeout() { let (_tx, rx) = mpsc::channel::(); let event = recv_event(&rx, Duration::from_millis(5)).expect("timeout is not an error"); - assert_eq!(event, AppEvent::Tick); + assert!(matches!(event, AppEvent::Tick)); } #[test] @@ -794,7 +962,7 @@ mod tests { let (tx, rx) = mpsc::channel::(); tx.send(Ok(AppEvent::Key(key(KeyCode::Char('q'))))).unwrap(); let event = recv_event(&rx, Duration::from_secs(1)).unwrap(); - assert_eq!(event, AppEvent::Key(key(KeyCode::Char('q')))); + assert!(is_key_event(&event, key(KeyCode::Char('q')))); } #[test] @@ -823,13 +991,9 @@ mod tests { tx.send(Ok(AppEvent::Key(key(KeyCode::Char('b'))))).unwrap(); let mut batch = Vec::new(); drain_pending(&rx, &mut batch).unwrap(); - assert_eq!( - batch, - vec![ - AppEvent::Key(key(KeyCode::Char('a'))), - AppEvent::Key(key(KeyCode::Char('b'))), - ] - ); + assert_eq!(batch.len(), 2); + assert!(is_key_event(&batch[0], key(KeyCode::Char('a')))); + assert!(is_key_event(&batch[1], key(KeyCode::Char('b')))); } #[test] @@ -1043,6 +1207,88 @@ mod tests { App::new(owned, diffs) } + // ── ADR-031: the loader job's pure body ────────────────────────────────────── + + #[test] + fn panic_message_reads_a_str_payload() { + let payload: Box = Box::new("boom"); + assert_eq!(panic_message(payload), "boom"); + } + + #[test] + fn panic_message_reads_a_string_payload() { + let payload: Box = Box::new("boom".to_string()); + assert_eq!(panic_message(payload), "boom"); + } + + #[test] + fn panic_message_falls_back_for_a_non_string_payload() { + let payload: Box = Box::new(42_i32); + assert_eq!(panic_message(payload), "loader job panicked"); + } + + #[test] + fn run_load_job_result_matches_a_synchronous_ensure_loaded() { + use git_workon_fixture::prelude::*; + use workon_review::app::Role; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut eager = app_from_fixture(&fixture); + eager.ensure_loaded(0); + let eager_view = eager.current_view_ref().expect("eager view loaded"); + let eager_old_text = eager_view.old_text().to_string(); + let eager_new_text = eager_view.new_text().to_string(); + + // Same two-handle shape the real loader thread uses: `app`'s own repo builds the spec, + // a SEPARATE repo + highlighter (standing in for `spawn_loader_thread`'s own) runs the + // job. + let mut app = app_from_fixture(&fixture); + app.set_defer_loads(true); + app.open_current(); + let (gen, cs_idx, file_idx, spec) = app + .take_pending_load_spec() + .expect("a fresh pending open has an undispatched spec"); + + let repo = fixture.repo().unwrap(); + let loader_repo = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut loader_ts = TsHighlighter::new(); + let event = run_load_job( + &loader_repo, + &mut loader_ts, + LoadRequest { + gen, + cs_idx, + file_idx, + spec, + }, + ); + + let AppEvent::FileReady { + gen: got_gen, + cs_idx: got_cs_idx, + file_idx: got_file_idx, + result, + } = event + else { + panic!("run_load_job must return a FileReady event"); + }; + assert_eq!(got_gen, gen); + assert_eq!(got_cs_idx, cs_idx); + assert_eq!(got_file_idx, file_idx); + + let LoadedViews::Single(role, Some(view)) = result.expect("job must not fail") else { + panic!("expected a loaded single-role view"); + }; + assert_eq!(role, Role::Unstaged, "a.txt has only an unstaged change"); + assert_eq!(view.old_text(), eager_old_text); + assert_eq!(view.new_text(), eager_new_text); + } + #[test] fn key_event_through_update_clears_a_previously_set_notice() { use git_workon_fixture::prelude::*; @@ -1754,14 +2000,19 @@ mod tests { let km = Keymap::defaults(); let mut pending_batch: Vec = Vec::new(); let mut pending_seq: Vec = Vec::new(); - let events = vec![ - AppEvent::Key(key(KeyCode::Char('j'))), - AppEvent::Key(key(KeyCode::Char('j'))), - AppEvent::Key(key(KeyCode::Char('j'))), - ]; + // `AppEvent` isn't `Clone` (ADR-031: `FileReady`'s payload wraps a non-`Clone` + // `FileView`), so the batch/sequential runs each build their own copy of the same + // three-key press sequence rather than sharing one `Vec` via `.clone()`. + let build_events = || { + vec![ + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + ] + }; - update_batch(&mut app_batch, &km, &mut pending_batch, events.clone()); - for event in events { + update_batch(&mut app_batch, &km, &mut pending_batch, build_events()); + for event in build_events() { update(&mut app_seq, &km, &mut pending_seq, event); } @@ -1790,15 +2041,19 @@ mod tests { let km = Keymap::defaults(); let mut pending_batch: Vec = Vec::new(); let mut pending_seq: Vec = Vec::new(); - let events = vec![ - AppEvent::Key(key(KeyCode::Char('j'))), - AppEvent::Key(key(KeyCode::Char('j'))), - AppEvent::Key(key(KeyCode::Char('j'))), - AppEvent::Key(key(KeyCode::Char('k'))), - ]; + // See the sibling test above for why this builds two independent copies rather than + // cloning one `Vec`. + let build_events = || { + vec![ + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('j'))), + AppEvent::Key(key(KeyCode::Char('k'))), + ] + }; - update_batch(&mut app_batch, &km, &mut pending_batch, events.clone()); - for event in events { + update_batch(&mut app_batch, &km, &mut pending_batch, build_events()); + for event in build_events() { update(&mut app_seq, &km, &mut pending_seq, event); } assert_eq!(app_batch.cursor, app_seq.cursor); @@ -1817,18 +2072,20 @@ mod tests { let mut app_seq2 = many_files_app(&fixture_seq2, 1); let mut pending_batch2: Vec = Vec::new(); let mut pending_seq2: Vec = Vec::new(); - let boundary_events = vec![ - AppEvent::Key(key(KeyCode::Char('k'))), - AppEvent::Key(key(KeyCode::Char('j'))), - ]; + let build_boundary_events = || { + vec![ + AppEvent::Key(key(KeyCode::Char('k'))), + AppEvent::Key(key(KeyCode::Char('j'))), + ] + }; update_batch( &mut app_batch2, &km, &mut pending_batch2, - boundary_events.clone(), + build_boundary_events(), ); - for event in boundary_events { + for event in build_boundary_events() { update(&mut app_seq2, &km, &mut pending_seq2, event); } assert_eq!(app_batch2.cursor, app_seq2.cursor); @@ -1902,13 +2159,15 @@ mod tests { let mut pending_batch: Vec = Vec::new(); let mut pending_seq: Vec = Vec::new(); let cursor_before = app_batch.cursor; - let events = vec![ - AppEvent::Key(key(KeyCode::Char('j'))), // swallowed by the confirm modal - AppEvent::Key(key(KeyCode::Char('n'))), // cancels the confirm - ]; + let build_events = || { + vec![ + AppEvent::Key(key(KeyCode::Char('j'))), // swallowed by the confirm modal + AppEvent::Key(key(KeyCode::Char('n'))), // cancels the confirm + ] + }; - update_batch(&mut app_batch, &km, &mut pending_batch, events.clone()); - for event in events { + update_batch(&mut app_batch, &km, &mut pending_batch, build_events()); + for event in build_events() { update(&mut app_seq, &km, &mut pending_seq, event); } From a694f3f76942ad9e7863b79179b0cb67499ca42e Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 10:04:21 -0400 Subject: [PATCH 088/203] fix(review): make load-spec building total for fileless changesets --- git-workon-review/src/app.rs | 54 ++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index dc3956f..03595e4 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1607,13 +1607,19 @@ impl App { /// what [`Self::ensure_loaded`] would read from live state — see [`FileLoadSpec`]'s doc /// comment. Every field is owned (cloned out), so the spec outlives the borrow and can cross /// to the loader thread. - fn current_load_spec(&self) -> FileLoadSpec { + /// + /// `None` when the current changeset's file list doesn't have an entry at `self.current` — + /// a clean uncommitted layer (zero files) is the common case, and a Pending/Failed slot + /// (also zero files) will join this once ADR-031's later changesets land. Total by + /// construction rather than relying on callers to guard first. + fn current_load_spec(&self) -> Option { let idx = self.current; let zoom = self.effective_zoom_for(idx); let diff = &self.cur().diff; - FileLoadSpec { + let combined_file = diff.files.get(idx)?.clone(); + Some(FileLoadSpec { span: self.cur().cs.span, - combined_file: diff.files[idx].clone(), + combined_file, zoom, unstaged_file: diff .unstaged_idx @@ -1627,7 +1633,7 @@ impl App { .copied() .flatten() .map(|mi| diff.staged_model.files[mi].clone()), - } + }) } /// Take the [`FileLoadSpec`] for the current pending open, tagged with the generation/ @@ -1635,18 +1641,18 @@ impl App { /// for this same pending open (see [`Self::open_pending_dispatched`]'s doc comment). The event /// loop calls this on every idle `Tick` while an open is pending; without the dispatched guard /// it would re-send the same request on every one of those ticks until the loader answers. - /// Returns `None` when nothing is pending, or a request already went out for it. + /// Returns `None` when nothing is pending, a request already went out for it, or the + /// current file has no spec to build (see [`Self::current_load_spec`]) — the pending flags + /// are left alone in that last case, matching upstack's eventual empty-file guard in + /// [`Self::open_current`]: this is a total fallback for a defer that outraced it, not a + /// second copy of that guard. pub fn take_pending_load_spec(&mut self) -> Option<(u64, usize, usize, FileLoadSpec)> { if !self.open_pending || self.open_pending_dispatched { return None; } + let spec = self.current_load_spec()?; self.open_pending_dispatched = true; - Some(( - self.generation, - self.current_cs, - self.current, - self.current_load_spec(), - )) + Some((self.generation, self.current_cs, self.current, spec)) } /// Apply one loader result (ADR-031's chokepoint, the `FileReady` inbox arm routes here): @@ -3369,7 +3375,9 @@ mod tests { // exactly the two-handle shape `Tui::run`/`spawn_loader_thread` build for real. let mut spec_app = app_from_fixture(&fixture); spec_app.set_zoom(Zoom::Combined); - let spec = spec_app.current_load_spec(); + let spec = spec_app + .current_load_spec() + .expect("fixture has a file at index 0"); let repo = fixture.repo().unwrap(); let loader_repo = Repository::open(repo.workdir().unwrap()).expect("loader's own repo handle"); @@ -3450,6 +3458,28 @@ mod tests { ); } + #[test] + fn take_pending_load_spec_is_none_for_a_fileless_changeset_without_panicking() { + // A clean uncommitted layer diffs to zero files. A pending open onto it (e.g. one that + // outraces a refresh, or the Pending/Failed slots ADR-031's later changesets introduce) + // must not panic `current_load_spec`'s file-list indexing — F7's regression. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + assert!(app.files().is_empty(), "fixture must have no diffed files"); + app.set_defer_loads(true); + app.open_current(); + assert!(app.open_pending(), "empty-file open still marks pending"); + + assert!( + app.take_pending_load_spec().is_none(), + "no spec can be built for a file that doesn't exist" + ); + } + #[test] fn apply_file_ready_drops_a_stale_generation_result() { let fixture = FixtureBuilder::new() From 769cf06ec1a6a97b376f9cbeb582b8ac8900d305 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 10:07:18 -0400 Subject: [PATCH 089/203] fix(review): re-dispatch a deferred open when zoom outran its load --- git-workon-review/src/app.rs | 102 +++++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 10 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 03595e4..12e0d44 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1666,11 +1666,18 @@ impl App { /// notice (never silently stranding the file — see this changeset's report for why a footer /// notice, not a new per-file `Failed` state, is the shape chosen here). /// - /// Either way, when the readied file IS the current pending open, it's seated exactly like - /// [`Self::complete_pending_open`]'s tail: `open_pending` clears regardless of `Ok`/`Err` — a - /// failed load must not leave the placeholder stuck forever. Correctness never depends on - /// this: the next nav/force-completion retries via [`Self::ensure_loaded`]'s ordinary - /// cache-miss path, which is where a load actually being CORRECT is guaranteed. + /// Either way, when the readied file IS the current pending open, it's seated like + /// [`Self::complete_pending_open`]'s tail — with one refinement over a plain "always clear" + /// rule: an `Ok` result only clears the pending open when its SHAPE satisfies the current + /// effective zoom (see [`loaded_views_satisfy`]). Without this, a zoom cycled mid-load + /// (`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 + /// `false` so the next idle Tick re-dispatches against the NOW-current zoom — mirroring a + /// fresh [`Self::open_current`] defer. An `Err` result keeps clearing unconditionally: a + /// failed load must not leave the placeholder stuck forever, and correctness never depends + /// on this path — the sync fallback owns correctness (see this method's summary above). pub fn apply_file_ready( &mut self, gen: u64, @@ -1681,8 +1688,12 @@ impl App { if gen != self.generation { return; } + let is_current_pending = + cs_idx == self.current_cs && file_idx == self.current && self.open_pending; match result { Ok(views) => { + let satisfies_current_zoom = is_current_pending + && loaded_views_satisfy(&views, self.effective_zoom_for(file_idx)); if let Some(cs) = self.changesets.get_mut(cs_idx) { match views { LoadedViews::Single(role, view) => set_if_absent(cs, role, file_idx, view), @@ -1692,16 +1703,25 @@ impl App { } } } + if is_current_pending { + if satisfies_current_zoom { + self.reset_panes(); + self.open_pending = false; + self.open_pending_dispatched = false; + } else { + self.open_pending_dispatched = false; + } + } } Err(message) => { self.notify(format!("failed to load file: {message}"), Severity::Error); + if is_current_pending { + self.reset_panes(); + self.open_pending = false; + self.open_pending_dispatched = false; + } } } - if cs_idx == self.current_cs && file_idx == self.current && self.open_pending { - self.reset_panes(); - self.open_pending = false; - self.open_pending_dispatched = false; - } } /// Cycle the requested zoom `Split → Combined → Unstaged → Staged → Split` (`z`). The new zoom @@ -2911,6 +2931,20 @@ 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` +/// 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 { + match (views, current_zoom) { + (LoadedViews::Single(role, _), EffectiveZoom::Single(want)) => *role == want, + (LoadedViews::Split { .. }, EffectiveZoom::Split) => true, + _ => false, + } +} + /// Build every [`FileView`] a [`FileLoadSpec`] needs, against `repo`/`ts` — the ADR-031 loader /// thread's pure job body: unit-testable directly against a fixture repo, no threads or channels /// involved. Routes through the SAME [`build_combined_view`]/[`build_sub_role_view`] free @@ -3437,6 +3471,54 @@ mod tests { assert_eq!(deferred_view.display.len(), eager_view.display.len()); } + #[test] + fn apply_file_ready_redispatches_when_zoom_outran_the_in_flight_load() { + // F2 regression: a zoom cycled mid-load must not let the stale-shaped in-flight result + // seat and clear the pending open — the new zoom's view would then never be dispatched. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .partially_staged_file("f.txt", "committed\n", "staged\n", "workdir\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.set_defer_loads(true); + // Default zoom is `Split`; this file has both staged and unstaged sub-diffs, so the + // effective zoom stays `Split` too. + app.open_current(); + assert!(app.open_pending(), "deferred open must be pending"); + + let (gen, cs_idx, file_idx, spec) = app + .take_pending_load_spec() + .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 + // against the NEW zoom instead of blocking for it. + app.cycle_zoom(); + assert!( + app.open_pending(), + "cycling zoom while a load is pending must still be pending" + ); + + // The loader answers the now-STALE (Split) request. + let repo = fixture.repo().unwrap(); + let loader_repo = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut loader_ts = crate::highlight::TsHighlighter::new(); + let views = build_file_views(&loader_repo, &mut loader_ts, &spec); + + app.apply_file_ready(gen, cs_idx, file_idx, Ok(views)); + + assert!( + app.open_pending(), + "a stale-shaped result must not clear the pending open" + ); + assert!( + app.take_pending_load_spec().is_some(), + "the next Tick must re-dispatch against the current (Combined) zoom" + ); + } + #[test] fn take_pending_load_spec_dispatches_at_most_once_per_pending_open() { let fixture = FixtureBuilder::new() From 441585eba846238de5bbf2231306bd7b36ad46a9 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 01:50:16 -0400 Subject: [PATCH 090/203] feat(review): stream startup changeset diffs behind a live outline --- git-workon-review/src/app.rs | 175 +++++++++++++++++++++++++++++++++ git-workon-review/src/main.rs | 180 ++++++++++++++++++++-------------- git-workon-review/src/tui.rs | 129 ++++++++++++++++++++++++ 3 files changed, 410 insertions(+), 74 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 12e0d44..0ebe725 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -892,6 +892,12 @@ pub struct App { /// ([`Self::apply_file_ready`]) — the ONLY drop rule; within a generation, results are cached /// even if the user navigated away (warmth, not staleness — see the ADR's "Generations"). generation: u64, + /// Whether the startup wave (ADR-031's streamed launch) has already raised its one footer + /// notice for a `ChangesetReady { result: Err }`. Set by [`Self::apply_changeset_ready`], + /// never cleared in this slice (only ONE wave — startup — exists yet; the refresh path stays + /// synchronous, so nothing re-arms it). "the wave's first failure raises a footer notice" — + /// this is what makes it FIRST, not every one of a bad stack's failures. + wave_failure_notified: bool, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -1031,6 +1037,7 @@ impl App { open_pending: false, open_pending_dispatched: false, generation: 1, + wave_failure_notified: 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 @@ -1724,6 +1731,60 @@ impl App { } } + /// Apply one streamed-diff wave result (ADR-031's `ChangesetReady` chokepoint, the streamed- + /// launch counterpart to [`Self::apply_file_ready`]): dropped outright on a generation + /// mismatch, same rule and same reason (the world the wave was diffing no longer exists — + /// e.g. a refresh ran mid-wave). Otherwise replaces changeset `idx`'s slot in place: + /// + /// - `Ok(diff)` builds its `Ready` [`ChangesetView`] via [`ChangesetView::from_changeset_diff`] + /// — the SAME router [`main.rs`'s lone-changeset sync path uses, so a streamed changeset's + /// `DiffState`/view caches are byte-identical to what a synchronous diff would have built. + /// - `Err(message)` builds a `Failed` slot carrying it ([`ChangesetView::failed`]); the wave's + /// FIRST failure (across the whole launch, not per-changeset) raises a footer notice — see + /// [`Self::wave_failure_notified`]'s doc comment — and the review continues (a stack with one + /// corrupt changeset still shows the other N-1). + /// + /// When `idx` IS the active changeset (the outline cursor already sits there — either it was + /// the lib-marked `current` changeset at launch, or the user navigated onto its still-`Pending` + /// placeholder), it's seated exactly as a fresh open would be: `current` resets to its first + /// file and [`Self::open_current`] runs (deferred-open semantics — CS4's placeholder shows + /// until the file itself loads), then the outline cursor resyncs. Nothing here requires the + /// user to navigate away and back for a just-readied active changeset to become interactive. + pub fn apply_changeset_ready( + &mut self, + gen: u64, + idx: usize, + result: Result, + ) { + if gen != self.generation { + return; + } + let Some(existing) = self.changesets.get(idx) else { + return; + }; + let cs = existing.cs.clone(); + match result { + Ok(diff) => { + self.changesets[idx] = ChangesetView::from_changeset_diff(cs, diff); + } + Err(message) => { + if !self.wave_failure_notified { + self.notify( + format!("failed to diff a changeset: {message}"), + Severity::Error, + ); + self.wave_failure_notified = true; + } + self.changesets[idx] = ChangesetView::failed(cs, message); + } + } + if idx == self.current_cs { + self.current = 0; + self.open_current(); + self.sync_outline_to_current(); + } + } + /// 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). @@ -6334,6 +6395,120 @@ mod tests { ); } + // ── ADR-031: the streamed-launch wave's chokepoint ─────────────────────────── + + #[test] + fn apply_changeset_ready_seats_the_active_changeset_when_its_diff_lands() { + let fixture = two_changes_one_hunk_fixture(); + let repo = fixture.repo().unwrap(); + let view_a = ChangesetView::pending(bare_changeset("cs-a", true)); + let view_b = ChangesetView::pending(bare_changeset("cs-b", false)); + 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()); + + let diffs = crate::acquire::diff_uncommitted(repo).unwrap(); + app.apply_changeset_ready( + app.generation(), + 0, + Ok(crate::acquire::ChangesetDiff::Uncommitted(diffs)), + ); + + assert!( + !app.is_current_pending(), + "the readied ACTIVE changeset must be seated, not left Pending" + ); + assert!(!app.files().is_empty()); + assert!( + app.current_view_ref().is_some(), + "seating an active changeset opens its first file exactly like a fresh open would" + ); + } + + #[test] + fn apply_changeset_ready_marks_a_non_active_changeset_ready_without_disturbing_current() { + let fixture = two_changes_one_hunk_fixture(); + let repo = fixture.repo().unwrap(); + let view_a = ChangesetView::pending(bare_changeset("cs-a", true)); + let view_b = ChangesetView::pending(bare_changeset("cs-b", false)); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view_a, view_b]); + + let diffs = crate::acquire::diff_uncommitted(repo).unwrap(); + app.apply_changeset_ready( + app.generation(), + 1, + Ok(crate::acquire::ChangesetDiff::Uncommitted(diffs)), + ); + + assert_eq!(app.current_cs(), 0, "the active changeset must not move"); + assert!( + app.is_current_pending(), + "cs-a is still Pending — only cs-b's slot changed" + ); + app.next_changeset(); + assert!( + !app.is_current_pending(), + "cs-b's slot is now Ready after navigating onto it" + ); + } + + #[test] + fn apply_changeset_ready_drops_a_stale_generation_result() { + let fixture = two_changes_one_hunk_fixture(); + let repo = fixture.repo().unwrap(); + let view = ChangesetView::pending(bare_changeset("cs-a", true)); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view]); + let stale_gen = app.generation(); + app.generation += 1; // simulate a refresh landing between dispatch and result + + let diffs = crate::acquire::diff_uncommitted(repo).unwrap(); + app.apply_changeset_ready( + stale_gen, + 0, + Ok(crate::acquire::ChangesetDiff::Uncommitted(diffs)), + ); + + assert!( + app.is_current_pending(), + "a stale-generation result must not seat a changeset from a world that no longer exists" + ); + } + + #[test] + fn apply_changeset_ready_err_marks_failed_and_notifies_only_on_the_first_failure() { + let fixture = two_changes_one_hunk_fixture(); + let repo = fixture.repo().unwrap(); + let view_a = ChangesetView::pending(bare_changeset("cs-a", true)); + let view_b = ChangesetView::pending(bare_changeset("cs-b", false)); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view_a, view_b]); + assert!(app.notice.is_none()); + + app.apply_changeset_ready(app.generation(), 0, Err("first failure".to_string())); + assert!( + app.current_failure().is_some(), + "the active changeset's Failed slot carries the message" + ); + let notice = app + .notice + .as_ref() + .expect("the wave's first failure raises a footer notice"); + assert_eq!(notice.severity, Severity::Error); + assert!(notice.text.contains("first failure")); + + // A SECOND failure in the same wave must not raise a second notice — only the wave's + // FIRST failure does (see `App::wave_failure_notified`'s doc comment). The review + // continues: cs-b's slot still becomes Failed even though no new notice fires. + app.apply_changeset_ready(app.generation(), 1, Err("second failure".to_string())); + let notice_after = app.notice.as_ref().unwrap(); + assert!( + notice_after.text.contains("first failure"), + "a second failure in the same wave must not overwrite the first's notice" + ); + } + #[test] fn staged_status_column_only_populated_for_the_uncommitted_changesets_files() { let mut app = committed_and_uncommitted_stack(); diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 9ca9d73..5995303 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -119,92 +119,124 @@ fn main() -> Result<()> { terminal_query::flush_pending_tty_input(); } - // CS5: take the terminal and show launch activity while the diffs build — on a deep stack - // this is the bulk of the launch, and until CS5 it left the terminal dead the whole time. - // Everything that could print, prompt, or flush is done (see the block comment above the - // resolve), so from here the terminal belongs to the TUI. `Tui`'s Drop restores it, so the - // `?`s below put the shell back before miette prints their error. + // CS5: take the terminal while the diffs build — on a deep stack this used to be the bulk of + // the launch with the terminal dead the whole time. Everything that could print, prompt, or + // flush is done (see the block comment above the resolve), so from here the terminal belongs + // to the TUI. `Tui`'s Drop restores it, so the `?`s below put the shell back before miette + // prints their error. // // An acquire FAILURE (no controlling tty — CI, a test harness, a bare pipe) is carried, not // propagated here: a clean worktree's "nothing to review" is only detectable AFTER the diff // below (resolve always yields at least the uncommitted changeset), and that exit must stay // tty-free, exactly as it was when the terminal was only taken inside the run call. The - // error surfaces at the run call — the same logical point it always did. Splash failures on - // an acquired terminal are cosmetic (the run call will surface anything real) and ignored. + // error surfaces at the run call — the same logical point it always did. let mut tui = tui::Tui::acquire(); - if let Ok(tui) = tui.as_mut() { - let noun = if changesets.len() == 1 { - "changeset" - } else { - "changesets" - }; - let _ = tui.splash(&format!("diffing {} {noun}…", changesets.len())); - } - let diffs = diff_changesets(&repo, &changesets).into_diagnostic()?; - let views: Vec = changesets - .into_iter() - .zip(diffs) - .map(|(cs, diff)| ChangesetView::from_changeset_diff(cs, diff)) - .collect(); - - // The single-uncommitted-changeset case with nothing in it only shows up in the built - // views' file counts — the mirror of the resolve-level empty check above, and the same - // "nothing to review" + exit 0 (ADR-030), never a `views` list handed to - // `App::from_changesets`, which panics on empty input. Restore the terminal BEFORE - // printing: the message must land on the normal screen, not vanish with the alternate one. - // A tty-less launch has no terminal to restore — the message prints exactly as before CS5. - if views.is_empty() || (views.len() == 1 && views[0].file_count() == 0) { + + // ADR-031: `main.rs` forks on `changesets.len()` — streaming's grain is per-changeset, so a + // lone changeset (the non-Graphite default, a ref/range, a PR) gains nothing from it and + // keeps today's synchronous path byte-identical (down to the `clean_worktree_prints_ + // nothing_to_review_and_exits_success` canary, which must stay tty-free). A real stack + // streams instead: the outline appears immediately with every row `Pending`, diffs land + // as they complete, and the splash — redundant once the first frame IS the live outline — + // is skipped entirely. + let repo_path = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf(); + + if changesets.len() == 1 { if let Ok(tui) = tui.as_mut() { - tui.restore().into_diagnostic()?; + let _ = tui.splash("diffing 1 changeset…"); } - match cli.source.as_deref() { - Some(text) => eprintln!("nothing to review in {text}"), - None => eprintln!("nothing to review"), + let diffs = diff_changesets(&repo, &changesets).into_diagnostic()?; + let views: Vec = changesets + .into_iter() + .zip(diffs) + .map(|(cs, diff)| ChangesetView::from_changeset_diff(cs, diff)) + .collect(); + + // The single-uncommitted-changeset case with nothing in it only shows up in the built + // views' file counts — the mirror of the resolve-level empty check above, and the same + // "nothing to review" + exit 0 (ADR-030), never a `views` list handed to + // `App::from_changesets`, which panics on empty input. Restore the terminal BEFORE + // printing: the message must land on the normal screen, not vanish with the alternate + // one. A tty-less launch has no terminal to restore — the message prints exactly as + // before CS5. + if views.is_empty() || (views.len() == 1 && views[0].file_count() == 0) { + if let Ok(tui) = tui.as_mut() { + tui.restore().into_diagnostic()?; + } + match cli.source.as_deref() { + Some(text) => eprintln!("nothing to review in {text}"), + None => eprintln!("nothing to review"), + } + return Ok(()); } - return Ok(()); - } - // Captured BEFORE `repo` moves into `App` below — the ADR-031 loader thread needs its own - // `Repository` handle onto the same on-disk repo (`git2::Repository` is `Send` but not - // `Sync`, so it can't cross threads directly), opened the same way - // `crate::acquire::diff_changesets`'s worker threads already do: at the workdir so the - // uncommitted layer's index/worktree diffs resolve correctly, falling back to the gitdir for - // a bare repo (where only committed spans can occur). - let repo_path = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf(); + // `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 = App::from_changesets(repo, views); + if let Some(source) = source { + app.set_review_source(source); + } + // CS4: defer file loads to the event loop's input-idle window rather than blocking here + // (or on any later selection change) — `app.open_current()` below marks the initial open + // pending instead of loading eagerly; see `tui::run`'s doc comment for the resulting + // startup contract. + app.set_defer_loads(true); + + // Apply CS7's view-config settings BEFORE `open_current`: `App::apply_view_config`'s + // setters only set the raw layout/zoom/mode/width fields, and `open_current` is what + // derives `cursor`/`scroll` fresh from whichever settings just landed (see each setter's + // doc comment). + 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. + let mut warnings = keymap.warnings().to_vec(); + warnings.extend(view_config_warnings); + if !warnings.is_empty() { + app.notify(warnings.join("; "), Severity::Error); + } - // `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 = App::from_changesets(repo, views); - if let Some(source) = source { - app.set_review_source(source); - } - // CS4: defer file loads to the event loop's input-idle window rather than blocking here (or - // on any later selection change) — `app.open_current()` below marks the initial open pending - // instead of loading eagerly; see `tui::run`'s doc comment for the resulting startup contract. - app.set_defer_loads(true); - - // Apply CS7's view-config settings BEFORE `open_current`: `App::apply_view_config`'s setters - // only set the raw layout/zoom/mode/width fields, and `open_current` is what derives - // `cursor`/`scroll` fresh from whichever settings just landed (see each setter's doc - // comment). - 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. - let mut warnings = keymap.warnings().to_vec(); - warnings.extend(view_config_warnings); - if !warnings.is_empty() { - app.notify(warnings.join("; "), Severity::Error); - } + // 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) + .into_diagnostic()?; + } else { + // Every changeset starts `Pending` (ADR-031's "Slots") — `App` is constructible from + // resolved-but-undiffed changesets, so the outline's headers render on the FIRST frame, + // before a single byte has been diffed. No splash: the live outline IS the launch + // feedback. + let views: Vec = changesets + .iter() + .cloned() + .map(ChangesetView::pending) + .collect(); + + let mut app = App::from_changesets(repo, views); + if let Some(source) = source { + app.set_review_source(source); + } + app.set_defer_loads(true); + let view_config_warnings = app.apply_view_config(&view_config); + // The active changeset is `Pending` (no files yet) — `open_current` is still the right + // call: it's a no-op on an empty file list, and re-running it the moment the active + // changeset's diff lands (`Tui::run_streamed`'s `ChangesetReady` handling) is what + // actually seats the first real file. + app.open_current(); + + let mut warnings = keymap.warnings().to_vec(); + warnings.extend(view_config_warnings); + if !warnings.is_empty() { + app.notify(warnings.join("; "), Severity::Error); + } - // 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) - .into_diagnostic()?; + tui.into_diagnostic()? + .run_streamed(&mut app, &keymap, &theme, repo_path, changesets) + .into_diagnostic()?; + } Ok(()) } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 35dc7a7..e497c92 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -33,6 +33,8 @@ use ratatui::backend::CrosstermBackend; use ratatui::style::{Modifier, Style}; 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::highlight::TsHighlighter; use workon_review::keymap::{Command, Dispatch, KeyPress, Keymap}; @@ -61,6 +63,17 @@ pub enum AppEvent { file_idx: usize, result: Result, }, + /// One changeset's streamed-diff result — ADR-031's streamed-launch counterpart to + /// `FileReady`, forwarded from the wave thread [`spawn_wave_thread`] spawns. `gen`/`idx` echo + /// the wave's stamp/the changeset's position in `App`'s stack; `result` is `Err` for a + /// changeset whose diff itself failed (a bad/garbage `Oid`, not a job panic — see + /// [`spawn_wave_thread`]'s doc comment). Applied at ONE chokepoint: + /// [`App::apply_changeset_ready`]. + ChangesetReady { + gen: u64, + idx: usize, + result: Result, + }, } impl PartialEq for AppEvent { @@ -216,6 +229,91 @@ fn spawn_loader_thread( req_tx } +/// Spawn the ADR-031 startup wave: stripe `changesets` (lib-`current` first, then input order) +/// across `available_parallelism`-many transient WORKER threads — same fan-out shape as +/// `crate::acquire::diff_changesets` (each worker opens its own `Repository`, since +/// `git2::Repository` is `Send` but not `Sync`) — but STREAM each result the instant it completes +/// via `tx` rather than joining the batch. Never joined itself either — a wave straggler left +/// running past quit is harmless (it only ever sends into an inbox nothing is listening to +/// anymore; `tx.send` failing is the signal each worker already checks). +/// +/// A DELIBERATELY separate set of threads from the loader thread (ADR-031 leaves this shape +/// open — "yours to shape"): the wave never touches the loader's request queue, so an in-flight +/// wave can never starve a `LoadFile` request behind it — they run on entirely disjoint threads +/// with entirely disjoint work queues. The cost is a second family of `Repository` handles +/// (`workers + 1`, alongside the loader's one) alive for the wave's brief lifetime; accepted for +/// the starvation-freedom it buys for free. +/// +/// A changeset whose own diff fails (a bad/garbage `Oid` — see [`diff_changeset`]'s doc comment) +/// sends `Err` for THAT changeset only; a worker whose own `Repository::open` fails sends `Err` +/// for every changeset in its chunk (mirroring `diff_changesets`' per-chunk failure shape) rather +/// than silently dropping them — every index must get exactly one result, or its slot stays +/// `Pending` forever with nothing left to complete it. +fn spawn_wave_thread( + repo_path: PathBuf, + tx: mpsc::Sender, + changesets: Vec, + gen: u64, +) { + thread::spawn(move || { + let n = changesets.len(); + if n == 0 { + return; + } + // Current changeset first, then input order for the rest — the changeset the user lands + // on becomes interactive earliest (ADR-031's "Slots"). + let current_idx = changesets.iter().position(|cs| cs.current); + let mut order: Vec = Vec::with_capacity(n); + order.extend(current_idx); + order.extend((0..n).filter(|&i| Some(i) != current_idx)); + + let workers = thread::available_parallelism() + .map(std::num::NonZeroUsize::get) + .unwrap_or(1) + .min(n); + let chunk = n.div_ceil(workers.max(1)); + + thread::scope(|scope| { + for idx_chunk in order.chunks(chunk) { + let tx = tx.clone(); + let changesets = &changesets; + let repo_path = &repo_path; + scope.spawn(move || { + let repo = match Repository::open(repo_path) { + Ok(repo) => repo, + Err(err) => { + let message = err.to_string(); + for &idx in idx_chunk { + if tx + .send(Ok(AppEvent::ChangesetReady { + gen, + idx, + result: Err(message.clone()), + })) + .is_err() + { + return; // main loop is gone + } + } + return; + } + }; + for &idx in idx_chunk { + let result = + diff_changeset(&repo, &changesets[idx]).map_err(|e| e.to_string()); + if tx + .send(Ok(AppEvent::ChangesetReady { gen, idx, result })) + .is_err() + { + return; // main loop is gone; nothing left to forward to + } + } + }); + } + }); + }); +} + /// Receive the next event from `inbox`, waiting up to `timeout`. A timeout with nothing received /// yields `Ok(AppEvent::Tick)` — the loop's regular redraw beat, and the mechanism the M4 index /// watcher polls on (see the module doc). A disconnected inbox (the input thread panicked, or @@ -547,6 +645,10 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap app.apply_file_ready(gen, cs_idx, file_idx, result); false } + AppEvent::ChangesetReady { gen, idx, result } => { + app.apply_changeset_ready(gen, idx, result); + false + } } } @@ -789,6 +891,33 @@ impl Tui { result.and(restored) } + /// ADR-031's streamed-launch counterpart to [`Self::run`]: for a stack of MORE than one + /// changeset, `main.rs` calls this instead — `app` is already constructible from + /// resolved-but-undiffed changesets (every slot `Pending`), and this is what starts the + /// diffing itself, alongside the input/loader threads `run` always spawns. No splash: the + /// first frame `event_loop` draws IS the live outline with `Pending` rows (see + /// `main.rs`'s block comment on the `changesets.len()` fork). + /// + /// `changesets` is the SAME resolved list `app`'s `Pending` slots were built from — handed + /// here (rather than re-read off `app`) since `App` only keeps [`workon_review::app:: + /// ChangesetView`]s, not the bare [`Changeset`]s the wave diffs against. + pub fn run_streamed( + &mut self, + app: &mut App, + keymap: &Keymap, + theme: &Palette, + repo_path: PathBuf, + changesets: Vec, + ) -> io::Result<()> { + let (tx, rx) = mpsc::channel::(); + spawn_input_thread(tx.clone()); + let load_tx = spawn_loader_thread(repo_path.clone(), tx.clone()); + spawn_wave_thread(repo_path, tx, changesets, app.generation()); + let result = event_loop(&mut self.terminal, app, keymap, theme, &rx, &load_tx); + let restored = self.restore(); + result.and(restored) + } + /// Put the terminal back (raw mode off, leave the alternate screen, cursor shown). Idempotent /// — a second call (including the one [`Drop`] always makes) is a no-op, so explicit callers /// (the "nothing to review" exit, which must restore BEFORE its `eprintln`) and the drop From 5958fb29f976084a8771cf79b9f4c0549c86faa9 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 10:10:21 -0400 Subject: [PATCH 091/203] fix(review): keep outline cursor anchored as streamed diffs land --- git-workon-review/src/app.rs | 93 ++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 0ebe725..62958a1 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1763,6 +1763,18 @@ impl App { return; }; let cs = existing.cs.clone(); + // F3: a landed NON-active changeset inserts file rows into the outline's row list, + // silently shifting a plain row-index cursor. Capture the identity of the row under the + // cursor now (before the slot swap rebuilds `outline_items()`) so it can be re-found + // afterward — the active-changeset case doesn't need this, since `sync_outline_to_current` + // below already repositions by diff identity, not row index. + let cursor_identity = if idx != self.current_cs { + self.outline_items() + .get(self.outline.cursor) + .and_then(outline_row_identity) + } else { + None + }; match result { Ok(diff) => { self.changesets[idx] = ChangesetView::from_changeset_diff(cs, diff); @@ -1782,6 +1794,18 @@ impl App { self.current = 0; self.open_current(); self.sync_outline_to_current(); + } else if let Some(identity) = cursor_identity { + let items = self.outline_items(); + if let Some(new_idx) = items + .iter() + .position(|it| outline_row_identity(it) == Some(identity)) + { + self.outline.cursor = new_idx; + } else { + // The identified row is gone (e.g. Flat mode deduped it out) — fall back to the + // same clamp `sync_outline_to_current` uses. + self.outline.cursor = self.outline.cursor.min(items.len().saturating_sub(1)); + } } } @@ -2956,6 +2980,21 @@ fn current_cs_index(changesets: &[ChangesetView]) -> usize { changesets.iter().position(|v| v.cs.current).unwrap_or(0) } +/// The `(cs_idx, file_idx)` identity an [`OutlineItem::Header`]/[`OutlineItem::File`] row +/// carries — `file_idx` is `None` for a header row. `OutlineItem::Dir` carries no `cs_idx` at +/// all (see its doc comment) and has no identity to preserve. Used by +/// [`App::apply_changeset_ready`] (F3) to re-find the row the outline cursor was on after a +/// streamed diff landing inserts/removes rows ahead of it in the row-index space. +fn outline_row_identity(item: &OutlineItem) -> Option<(usize, Option)> { + match item { + OutlineItem::Header { cs_idx, .. } => Some((*cs_idx, None)), + OutlineItem::File { + cs_idx, file_idx, .. + } => Some((*cs_idx, Some(*file_idx))), + OutlineItem::Dir { .. } => None, + } +} + // ── ADR-031: the loader thread's stateless request/job shape ──────────────────── /// Everything the ADR-031 loader job needs to reproduce one file's [`App::ensure_loaded`] work @@ -6509,6 +6548,60 @@ mod tests { ); } + #[test] + fn apply_changeset_ready_keeps_outline_cursor_anchored_when_an_earlier_non_active_changeset_lands( + ) { + // F3 regression: cs-a sits BEFORE the active cs-b in the outline row list. Landing cs-a's + // diff inserts its file rows ahead of cs-b's header, shifting every row-index cursor at + // or after cs-a's header — a plain row-index cursor would silently drift onto one of + // cs-a's new file rows instead of staying on cs-b's header. + let fixture = two_changes_one_hunk_fixture(); + let repo = fixture.repo().unwrap(); + let view_a = ChangesetView::pending(bare_changeset("cs-a", false)); + let view_b = ChangesetView::pending(bare_changeset("cs-b", true)); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view_a, view_b]); + app.outline.mode = OutlineMode::Stack; + assert_eq!( + app.current_cs(), + 1, + "cs-b is the lib-marked current changeset" + ); + + let items_before = app.outline_items(); + let cursor_before = items_before + .iter() + .position(|it| matches!(it, OutlineItem::Header { cs_idx: 1, .. })) + .expect("cs-b's header row exists before cs-a lands"); + app.outline.cursor = cursor_before; + + let diffs = crate::acquire::diff_uncommitted(repo).unwrap(); + app.apply_changeset_ready( + app.generation(), + 0, + Ok(crate::acquire::ChangesetDiff::Uncommitted(diffs)), + ); + + let items_after = app.outline_items(); + assert!( + items_after.len() > items_before.len(), + "cs-a's file rows must have been inserted ahead of cs-b's header" + ); + assert_eq!( + items_after[app.outline_cursor()], + OutlineItem::Header { + cs_idx: 1, + label: "cs-b".to_string(), + current: true, + needs_restack: false, + loading: true, + failed: false, + }, + "the outline cursor must still identify cs-b's header row, not whatever row now \ + sits at its old index" + ); + } + #[test] fn staged_status_column_only_populated_for_the_uncommitted_changesets_files() { let mut app = committed_and_uncommitted_stack(); From 83d461516e3ffa2a8603829a367b39ec52dd98a0 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 10:11:51 -0400 Subject: [PATCH 092/203] refactor(review): hoist the shared app-seating tail out of the launch fork --- git-workon-review/src/main.rs | 86 ++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 5995303..6150097 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -173,31 +173,7 @@ 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 = App::from_changesets(repo, views); - if let Some(source) = source { - app.set_review_source(source); - } - // CS4: defer file loads to the event loop's input-idle window rather than blocking here - // (or on any later selection change) — `app.open_current()` below marks the initial open - // pending instead of loading eagerly; see `tui::run`'s doc comment for the resulting - // startup contract. - app.set_defer_loads(true); - - // Apply CS7's view-config settings BEFORE `open_current`: `App::apply_view_config`'s - // setters only set the raw layout/zoom/mode/width fields, and `open_current` is what - // derives `cursor`/`scroll` fresh from whichever settings just landed (see each setter's - // doc comment). - 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. - let mut warnings = keymap.warnings().to_vec(); - warnings.extend(view_config_warnings); - if !warnings.is_empty() { - app.notify(warnings.join("; "), Severity::Error); - } + let mut app = seat_app(repo, views, source, &view_config, &keymap); // 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. @@ -215,23 +191,7 @@ fn main() -> Result<()> { .map(ChangesetView::pending) .collect(); - let mut app = App::from_changesets(repo, views); - if let Some(source) = source { - app.set_review_source(source); - } - app.set_defer_loads(true); - let view_config_warnings = app.apply_view_config(&view_config); - // The active changeset is `Pending` (no files yet) — `open_current` is still the right - // call: it's a no-op on an empty file list, and re-running it the moment the active - // changeset's diff lands (`Tui::run_streamed`'s `ChangesetReady` handling) is what - // actually seats the first real file. - app.open_current(); - - let mut warnings = keymap.warnings().to_vec(); - warnings.extend(view_config_warnings); - if !warnings.is_empty() { - app.notify(warnings.join("; "), Severity::Error); - } + let mut app = seat_app(repo, views, source, &view_config, &keymap); tui.into_diagnostic()? .run_streamed(&mut app, &keymap, &theme, repo_path, changesets) @@ -240,3 +200,45 @@ fn main() -> Result<()> { Ok(()) } + +/// 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. +fn seat_app( + repo: Repository, + views: Vec, + source: Option, + view_config: &config::RawViewConfig, + keymap: &Keymap, +) -> App { + let mut app = App::from_changesets(repo, views); + if let Some(source) = source { + app.set_review_source(source); + } + // CS4: defer file loads to the event loop's input-idle window rather than blocking here (or + // on any later selection change) — `app.open_current()` below marks the initial open pending + // instead of loading eagerly; see `tui::run`'s doc comment for the resulting startup + // contract. + app.set_defer_loads(true); + + // Apply CS7's view-config settings BEFORE `open_current`: `App::apply_view_config`'s setters + // only set the raw layout/zoom/mode/width fields, and `open_current` is what derives + // `cursor`/`scroll` fresh from whichever settings just landed (see each setter's doc + // comment). + 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. + let mut warnings = keymap.warnings().to_vec(); + warnings.extend(view_config_warnings); + if !warnings.is_empty() { + app.notify(warnings.join("; "), Severity::Error); + } + + app +} From 3e58f1b989c2c13860af6a5a55c4925ac235282c Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 02:26:35 -0400 Subject: [PATCH 093/203] feat(review): stream refresh diffs with span-keyed slot reuse --- git-workon-review/src/app.rs | 561 +++++++++++++++++++++++++++++++---- git-workon-review/src/tui.rs | 126 ++++++-- 2 files changed, 607 insertions(+), 80 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 62958a1..e0e98d8 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -730,6 +730,15 @@ impl ChangesetView { } } + /// Whether this changeset's diff is real and ready (ADR-031's third slot state, named from + /// the other side) — [`App::refresh`]'s span-keyed reuse reads this to decide which existing + /// slots may be carried over wholesale. Deliberately excludes `Failed` (reuse only carries + /// `Ready` slots — `r` naturally retries a failed one instead, see the ADR's "Failures") and + /// `Pending` (nothing yet to reuse). + fn is_ready(&self) -> bool { + matches!(self.slot, ChangesetSlot::Ready) + } + /// Build the [`ChangesetView`] for `cs` from its acquired [`ChangesetDiff`] (see /// [`crate::acquire::diff_changeset`]) — the router from "how was this changeset diffed" to /// the uniform [`DiffState`] shape every [`ChangesetView`] carries. @@ -892,12 +901,22 @@ pub struct App { /// ([`Self::apply_file_ready`]) — the ONLY drop rule; within a generation, results are cached /// even if the user navigated away (warmth, not staleness — see the ADR's "Generations"). generation: u64, - /// Whether the startup wave (ADR-031's streamed launch) has already raised its one footer - /// notice for a `ChangesetReady { result: Err }`. Set by [`Self::apply_changeset_ready`], - /// never cleared in this slice (only ONE wave — startup — exists yet; the refresh path stays - /// synchronous, so nothing re-arms it). "the wave's first failure raises a footer notice" — - /// this is what makes it FIRST, not every one of a bad stack's failures. + /// Whether the CURRENT wave (startup's, or the most recent refresh's) has already raised its + /// one footer notice for a `ChangesetReady { result: Err }`. Set by + /// [`Self::apply_changeset_ready`]; reset to `false` by every [`Self::refresh`] right + /// alongside the generation bump, since a refresh dispatching a NEW wave (ADR-031's "Refresh" + /// changeset) starts that wave's own "first failure" count over — otherwise a stack whose + /// first-ever wave had one bad changeset would never notify again for a LATER, unrelated + /// failure. "the wave's first failure raises a footer notice" — this is what makes it FIRST + /// per wave, not every one of a bad stack's failures within it. wave_failure_notified: bool, + /// The ADR-031 refresh wave [`Self::refresh`] most recently queued (span-keyed reuse's + /// changed/new committed spans, stamped with the generation they belong to), if any — taken + /// (and cleared) by [`Self::take_pending_wave`]. Mirrors [`Self::open_pending`]/ + /// [`Self::take_pending_load_spec`]'s shape: `App` computes WHAT needs diffing but never + /// 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)>)>, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -1038,6 +1057,7 @@ impl App { open_pending_dispatched: false, generation: 1, wave_failure_notified: false, + pending_wave: None, }; // 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 @@ -1170,31 +1190,60 @@ impl App { } /// Re-run [`crate::acquire::resolve_changesets`] against the CURRENT `HEAD` branch and - /// rebuild every [`ChangesetView`] from scratch — the operation both a manual refresh (`r`) - /// and (later) a post-staging-op/external-write refresh need. Re-assembling (not just - /// re-diffing the active changeset) matters because a restack can change the stack's - /// topology, not just its diffs. + /// rebuild [`Self::changesets`] — the operation both a manual refresh (`r`) and the + /// post-staging-op/external-write refresh need. Re-assembling (not just re-diffing the + /// active changeset) matters because a restack can change the stack's topology, not just its + /// diffs. + /// + /// ADR-031 "Refresh" — span-keyed reuse, uncommitted always sync: + /// + /// - Resolve (this method's first half) stays fully synchronous on the main thread — it's + /// offline and cheap, and re-running it on every refresh is what keeps [`Self::review_source`] + /// honored (see below). + /// - The rebuilt view list carries over any existing `Ready` slot whose `(name, span)` is + /// unchanged — a committed diff is a pure function of its span + /// ([`ChangesetSpan::Committed`] compares `base`/`head`; [`ChangesetSpan::CommittedRoot`] + /// compares `head`) — so an ordinary post-staging refresh re-diffs *nothing but the + /// uncommitted layer*. A carried slot keeps its `DiffState` AND warm view caches verbatim: + /// never blanked, never re-diffed. Reuse only ever carries a `Ready` slot — a `Failed` one + /// goes back through the `Pending`+wave path below, which is how `r` naturally retries it + /// with no separate retry machinery. + /// - The [`ChangesetSpan::Uncommitted`] layer is never "unchanged": it re-diffs + /// SYNCHRONOUSLY, right here, on every refresh — ms-scale, and this is what preserves + /// staging's guarantee that the next keystroke sees the post-op world (an async refresh + /// would let a second `s` compute its patch against a stale diff). A failed sync re-diff + /// becomes a `Failed` slot plus a footer notice (an explicit error beats stale wrong + /// content) rather than aborting the whole refresh. + /// - Every other changed-or-new committed span becomes a `Pending` slot; the caller (the + /// event loop, via [`Self::take_pending_wave`]) dispatches those as an async wave, current- + /// first if the active changeset is among them, same as the streamed-launch wave. Their + /// results land through [`Self::apply_changeset_ready`] tagged with the NEW generation. + /// - Every refresh bumps the generation exactly once, right where the view caches it protects + /// are actually replaced — reused (carried) slots' in-flight loader results now carry a + /// stale `gen` and die at [`Self::apply_file_ready`]'s chokepoint; accepted waste for one + /// global rule (see the ADR's "Generations"). [`Self::wave_failure_notified`] resets + /// alongside it, so the freshly-dispatched wave gets its own first-failure notice. /// - /// - Rebuilds [`Self::changesets`] and [`Self::base_label`] in place. Does NOT touch `repo` - /// (same handle), `highlighter` (its per-instance grammar cache would have to re-parse - /// every language from scratch if rebuilt), `layout`, or `zoom` (the user's current view - /// mode shouldn't reset just because they pressed `r`, or because a background refresh - /// fired). + /// Position rules, adapted to the streamed world: /// - Preserves the active changeset by NAME: if a changeset with that name still exists in /// the rebuilt stack, `current_cs` follows it; otherwise it falls back to whichever /// changeset the lib now reports as `current`, or index `0`. - /// - Preserves file position by PATH within the (possibly different) active changeset, same - /// rule M4 used: `current` follows the path if it still exists, else clamps into the new - /// list (or `0` if empty). - /// - Re-seats the (possibly changed) current file at its first hunk via [`Self::open_current`] - /// — the same path a file switch already uses. This does NOT try to preserve the exact - /// cursor row: the rows under an old cursor position may no longer correspond to the same - /// content once the diff is rebuilt, so jumping to the first hunk (like opening a file fresh) - /// is the only always-valid choice, consistent with how zoom/layout switches already treat - /// cursor position as non-transferable across a reshape. + /// - A carried (still-`Ready`) active changeset — or the always-sync uncommitted layer — + /// preserves file position by PATH exactly like before streaming: `current` follows the + /// path if it still exists, else clamps into the new list (or `0` if empty), then + /// [`Self::open_current`] re-seats it at the first hunk (this does NOT try to preserve the + /// exact cursor row — jumping to the first hunk is the only always-valid choice once the + /// diff is rebuilt, consistent with zoom/layout switches). An active changeset that went + /// `Pending` instead has no diff yet to preserve a path INTO — `current` resets to `0` and + /// [`Self::apply_changeset_ready`] re-seats it (to its first file) exactly as it already + /// does for a freshly-`Pending` changeset, once that `ChangesetReady` lands. + /// - The rebuilt changeset list can resize/reorder the outline's row list out from under its + /// cursor — reposition it, same as every other diff-initiated nav (does NOT touch + /// `outline.open`/`focused`/`mode`, which persist across a refresh like `layout`/`zoom`). /// - /// On any assembly/diff error, leaves all existing state untouched and sets an error - /// [`Notice`] instead (via [`Self::notify`]) — a failed refresh must never blank the review. + /// On a resolve/assembly error (or the uncommitted layer's own sync re-diff failing — see + /// above), leaves the rest of `Self::changesets` untouched and sets an error [`Notice`] + /// instead (via [`Self::notify`]) — a failed refresh must never blank the review. /// /// Dispatches on [`Self::review_source`] (M7 CS2 fix): a no-argument launch (`None`) re-runs /// today's auto-detect ([`crate::acquire::resolve_changesets`]); an explicit-source launch @@ -1234,22 +1283,9 @@ impl App { return; } }; - - let diffs = match crate::acquire::diff_changesets(&self.repo, &changesets) { - Ok(diffs) => diffs, - Err(err) => { - self.notify(format!("refresh failed: {err}"), Severity::Error); - return; - } - }; - let views: Vec = changesets - .into_iter() - .zip(diffs) - .map(|(cs, diff)| ChangesetView::from_changeset_diff(cs, diff)) - .collect(); // `resolve_changesets` always returns at least one changeset (a lone Uncommitted entry // when no stack is active), but stay defensive rather than index an empty `Vec` below. - if views.is_empty() { + if changesets.is_empty() { self.notify("refresh failed: no changesets to review", Severity::Error); return; } @@ -1262,28 +1298,80 @@ impl App { .get(self.current) .map(|f| f.path.clone()); - self.current_cs = views + // Span-keyed reuse: pull the OLD view list out so a `Ready` slot whose `(name, span)` + // survives can be moved (not cloned) into the rebuilt list, keeping its warm view caches. + // `Vec::remove`'s O(n) shift is immaterial at stack sizes (a handful of changesets). + let mut old_views = std::mem::take(&mut self.changesets); + + let mut new_views: Vec = Vec::with_capacity(changesets.len()); + let mut to_diff: Vec<(usize, Changeset)> = Vec::new(); + let mut uncommitted_diff_failed: Option = None; + + for cs in changesets { + if cs.span == ChangesetSpan::Uncommitted { + match crate::acquire::diff_changeset(&self.repo, &cs) { + Ok(diff) => new_views.push(ChangesetView::from_changeset_diff(cs, diff)), + Err(err) => { + let message = err.to_string(); + uncommitted_diff_failed = Some(message.clone()); + new_views.push(ChangesetView::failed(cs, message)); + } + } + continue; + } + if let Some(pos) = old_views + .iter() + .position(|v| v.is_ready() && v.cs.name == cs.name && v.cs.span == cs.span) + { + // Carry the slot's diff/view caches verbatim, but adopt the FRESH descriptor — + // metadata like `needs_restack` can change even when the span itself didn't. + let mut reused = old_views.remove(pos); + reused.cs = cs; + new_views.push(reused); + } else { + let idx = new_views.len(); + to_diff.push((idx, cs.clone())); + new_views.push(ChangesetView::pending(cs)); + } + } + + self.current_cs = new_views .iter() .position(|v| v.cs.name == prev_cs_name) - .unwrap_or_else(|| current_cs_index(&views)); - self.base_label = base_label_for(&views[self.current_cs].cs); - self.changesets = views; + .unwrap_or_else(|| current_cs_index(&new_views)); + self.base_label = base_label_for(&new_views[self.current_cs].cs); + self.changesets = new_views; // ADR-031: every refresh bumps the generation, right where the view caches it protects - // are actually replaced — an early `return` above (a failed resolve/diff) leaves the old + // are actually replaced — an early `return` above (a failed resolve) leaves the old // world's caches intact, so it must NOT bump. Any loader result still in flight for the - // pre-refresh world now carries a stale `gen` and dies at `apply_file_ready`'s chokepoint. + // pre-refresh world now carries a stale `gen` and dies at `apply_file_ready`'s chokepoint; + // same for a wave result still in flight for a superseded generation. self.generation += 1; + self.wave_failure_notified = false; - let n = self.cur().diff.files.len(); - self.current = current_path - .and_then(|path| self.cur().diff.files.iter().position(|f| f.path == path)) - .unwrap_or(if n == 0 { 0 } else { self.current.min(n - 1) }); - + if self.cur().is_pending() { + self.current = 0; + } else { + let n = self.cur().diff.files.len(); + self.current = current_path + .and_then(|path| self.cur().diff.files.iter().position(|f| f.path == path)) + .unwrap_or(if n == 0 { 0 } else { self.current.min(n - 1) }); + } self.open_current(); - // The rebuilt changeset list can resize/reorder the outline's row list out from under - // its cursor — reposition it, same as every other diff-initiated nav (does NOT touch - // `outline.open`/`focused`/`mode`, which persist across a refresh like `layout`/`zoom`). self.sync_outline_to_current(); + + if let Some(err) = uncommitted_diff_failed { + self.notify( + format!("refresh failed: uncommitted diff failed: {err}"), + Severity::Error, + ); + } + + self.pending_wave = if to_diff.is_empty() { + None + } else { + Some((self.generation, to_diff)) + }; } /// Resolve the [`EffectiveZoom`] for file `idx` this frame: the requested [`Self::zoom`] gated @@ -1560,7 +1648,13 @@ impl App { /// navigation of all, and it must render immediately. Outside defer mode this is exactly /// the pre-defer eager behavior. pub fn open_current(&mut self) { - if self.defer_loads && !self.current_views_cached() { + // An empty file list (a `Pending`/`Failed` slot, ADR-031, or a genuinely empty committed + // changeset) has nothing to defer: `current_load_spec` indexes `diff.files[self.current]` + // unconditionally, which would panic on the loader-dispatch path if `open_pending` were + // set here for a file that doesn't exist. There's nothing to load either way — this is + // the same "no-op on an empty file list" contract `main.rs`'s streamed-launch comment + // documents for a fresh `Pending` changeset, made actually true rather than incidental. + if self.defer_loads && !self.files().is_empty() && !self.current_views_cached() { self.open_pending = true; // A fresh pending open has nothing dispatched to the loader yet — see // [`Self::take_pending_load_spec`]. @@ -1662,6 +1756,19 @@ impl App { Some((self.generation, self.current_cs, self.current, spec)) } + /// Take the ADR-031 refresh wave [`Self::refresh`] most recently queued (span-keyed reuse's + /// changed/new committed spans), if any — `None` when the last refresh had nothing left to + /// diff asynchronously (every span was reused or is the always-sync uncommitted layer; this + /// is what keeps a single-uncommitted-changeset session's refresh effectively synchronous, + /// see the ADR's "Refresh"). Mirrors [`Self::take_pending_load_spec`]'s take-once shape: the + /// caller (the event loop — the only place with thread-spawning ability) is responsible for + /// actually dispatching it; `App` never touches a `Sender`/`Repository`-carrying handle + /// itself, so it stays constructible — and `refresh` stays synchronously testable — with + /// nothing wired up to consume this at all. + pub fn take_pending_wave(&mut self) -> Option<(u64, Vec<(usize, Changeset)>)> { + self.pending_wave.take() + } + /// Apply one loader result (ADR-031'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: @@ -4752,6 +4859,352 @@ mod tests { ); } + // ---- ADR-031 refresh: span-keyed reuse, uncommitted always sync, async waves ---------- + + /// Build a two-commit chain (`root` then `head`) on the fixture's default branch and return + /// both `Oid`s — the shared setup every span-keyed-reuse test below diffs a + /// [`ChangesetSpan::Committed`] across. + fn root_and_head_commits(fixture: &Fixture) -> (git2::Oid, git2::Oid) { + let root = fixture + .commit("main") + .file("r.txt", "r\n") + .create("root") + .unwrap(); + let head = fixture + .commit("main") + .file("a.txt", "a\n") + .file("b.txt", "b\n") + .create("head") + .unwrap(); + (root, head) + } + + #[test] + fn refresh_reuses_a_ready_committed_slot_with_unchanged_span_keeping_warm_caches() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let (root, head) = root_and_head_commits(&fixture); + let repo = fixture.repo().unwrap(); + + // `head_text: "main"` re-resolves through the branch ref on every refresh — the span + // stays `Committed { base: root, head }` as long as `main` doesn't move, exercising the + // REAL re-resolve path (not a hand-frozen span) for the "unchanged" case. + let cs = Changeset { + name: format!("{root}..main"), + 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.set_review_source(crate::source::Source::Range { + base_text: root.to_string(), + head_text: "main".to_string(), + dots: crate::source::RangeDots::Two, + }); + + app.open_current(); // caches file 0 ("a.txt") + assert_eq!(app.files().len(), 2, "root..head touches a.txt and b.txt"); + app.next_file(); // caches file 1 ("b.txt") too + app.prev_file(); // back to file 0 — the file `refresh`'s tail will re-seat + assert!(app.role_view_ref(1, Role::Combined).is_some()); + + let gen_before = app.generation(); + app.refresh(); + + assert_eq!( + app.generation(), + gen_before + 1, + "every refresh bumps the generation, reused slot or not" + ); + assert!( + !app.is_current_pending(), + "an unchanged span must be carried over Ready, never go through Pending" + ); + assert_eq!(app.files().len(), 2); + assert_eq!(app.current, 0, "file position by path is preserved"); + assert!( + app.role_view_ref(1, Role::Combined).is_some(), + "file 1's view cache must survive untouched — refresh's tail only (re)opens the \ + CURRENT file (0), so a still-populated cache at 1 proves the whole ChangesetView \ + (not just its diff) was carried over rather than rebuilt fresh" + ); + assert!( + app.take_pending_wave().is_none(), + "a fully-reused refresh has nothing left to diff asynchronously" + ); + } + + #[test] + fn refresh_sends_a_changed_committed_span_through_a_wave_instead_of_reusing_it() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let (root, old_head) = root_and_head_commits(&fixture); + let repo = fixture.repo().unwrap(); + + let cs = Changeset { + name: format!("{root}..main"), + span: ChangesetSpan::Committed { + base: root, + head: old_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.set_review_source(crate::source::Source::Range { + base_text: root.to_string(), + head_text: "main".to_string(), + dots: crate::source::RangeDots::Two, + }); + app.open_current(); + + // Advance `main` past `old_head` — the same shape as a real amend/restack: the name + // ("{root}..main") stays identical, but the span's `head` moves. + let new_head = fixture + .commit("main") + .file("c.txt", "c\n") + .create("new head") + .unwrap(); + + let gen_before = app.generation(); + app.refresh(); + let gen_after = app.generation(); + assert_eq!(gen_after, gen_before + 1); + + assert!( + app.is_current_pending(), + "a changed span must NOT be reused — it goes Pending for the wave to diff" + ); + assert!(app.files().is_empty()); + + let (wave_gen, to_diff) = app + .take_pending_wave() + .expect("a changed committed span must queue a wave request"); + assert_eq!(wave_gen, gen_after); + assert_eq!(to_diff.len(), 1); + assert_eq!( + to_diff[0].0, 0, + "the stack index the result must be seated at" + ); + assert_eq!( + to_diff[0].1.span, + ChangesetSpan::Committed { + base: root, + head: new_head, + }, + "the wave must diff the NEW span, not the stale one" + ); + assert!( + app.take_pending_wave().is_none(), + "take_pending_wave is a take-once — a second call must find nothing left" + ); + + // A stale-generation result (as if it were still in flight for the pre-refresh world) + // must be dropped outright. + app.apply_changeset_ready( + gen_before, + 0, + Ok(crate::acquire::ChangesetDiff::Committed( + crate::acquire::diff_committed(repo, root, new_head).unwrap(), + )), + ); + assert!( + app.is_current_pending(), + "a stale-generation ChangesetReady must be dropped, not seat the changeset" + ); + + // The NEW generation's result lands and seats the (still active) changeset. + app.apply_changeset_ready( + gen_after, + 0, + Ok(crate::acquire::ChangesetDiff::Committed( + crate::acquire::diff_committed(repo, root, new_head).unwrap(), + )), + ); + assert!(!app.is_current_pending()); + assert_eq!( + app.files().len(), + 3, + "root..new_head accumulates a.txt/b.txt (from `head`) and c.txt (from `new_head`)" + ); + assert_eq!( + app.cur().cs.span, + ChangesetSpan::Committed { + base: root, + head: new_head, + } + ); + } + + #[test] + fn refresh_retries_a_failed_committed_slot_via_a_wave_rather_than_reusing_it() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let (root, head) = root_and_head_commits(&fixture); + let repo = fixture.repo().unwrap(); + + let cs = Changeset { + name: format!("{root}..main"), + span: ChangesetSpan::Committed { base: root, head }, + title: None, + current: true, + needs_restack: false, + }; + // Seed the slot as `Failed` for this exact (name, span) — as if a previous wave's diff + // for it had errored. + let view = ChangesetView::failed(cs, "a previous diff attempt failed"); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view]); + app.set_review_source(crate::source::Source::Range { + base_text: root.to_string(), + head_text: "main".to_string(), + dots: crate::source::RangeDots::Two, + }); + assert!(app.current_failure().is_some()); + + app.refresh(); // span is UNCHANGED from the Failed slot's — reuse must still skip it + + assert!( + app.is_current_pending(), + "reuse only carries `Ready` slots — a `Failed` one goes back through Pending+wave, \ + which is what makes `r` a retry with no separate retry machinery" + ); + let (_, to_diff) = app + .take_pending_wave() + .expect("the retried span must be queued for the wave"); + assert_eq!(to_diff.len(), 1); + assert_eq!( + to_diff[0].1.span, + ChangesetSpan::Committed { base: root, head } + ); + } + + #[test] + fn coordinated_refresh_after_staging_rebuilds_the_uncommitted_layer_synchronously_while_reusing_an_unchanged_committed_span( + ) { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .unstaged_file("dirty.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + repo.set_head("refs/heads/a").unwrap(); + repo.checkout_head(None).unwrap(); + + let changesets = crate::acquire::resolve_changesets(repo, "a").unwrap(); + assert_eq!( + changesets.len(), + 2, + "expected the 'a' Graphite node plus the dirty tree's uncommitted layer" + ); + let diffs = crate::acquire::diff_changesets(repo, &changesets).unwrap(); + let views: Vec = changesets + .into_iter() + .zip(diffs) + .map(|(cs, diff)| ChangesetView::from_changeset_diff(cs, diff)) + .collect(); + + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, views); + assert_eq!( + app.cur().cs.span, + ChangesetSpan::Uncommitted, + "opens on the uncommitted layer (lib-`current`)" + ); + + app.open_current(); // cursor lands on dirty.txt's one hunk + app.stage_hunk(); // run_op -> coordinated_refresh -> refresh, synchronously + + // The post-op world is visible SYNCHRONOUSLY, before the next event loop iteration. + repo.assert(predicate::repo::has_staged_file("dirty.txt")); + assert!( + !app.is_current_pending(), + "the uncommitted layer always re-diffs sync — it must never go through Pending" + ); + assert!( + app.take_pending_wave().is_none(), + "the 'a' node's committed span didn't change — staging must not have dispatched a \ + wave for it" + ); + } + + #[test] + fn refresh_marks_the_uncommitted_layer_failed_when_its_sync_diff_errors() { + use super::Severity; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.set_review_source(crate::source::Source::Uncommitted); + // Deliberately do NOT call `app.open_current()` here: reading a file's content already + // walks `HEAD`'s commit/tree chain through `app`'s OWN `Repository` handle, which would + // warm libgit2's per-handle object cache for the exact commit this test corrupts below — + // a cache hit would silently mask the corruption instead of exercising the failure path. + + // Corrupt the loose object `HEAD` points to (not the `HEAD` ref itself): `repo.head()`'s + // SHORTHAND still resolves fine (refresh's own branch-name read, and + // `Source::Uncommitted`'s resolution, which is a pure string wrap needing no repo access + // at all), but `diff_uncommitted`'s `repo.head()?.peel_to_tree()` — which walks all the + // way to the commit object, through `app`'s never-yet-used-for-this-object handle — fails. + let repo = fixture.repo().unwrap(); + let head_oid = repo.head().unwrap().target().unwrap(); + let hex = head_oid.to_string(); + let object_path = repo.path().join("objects").join(&hex[0..2]).join(&hex[2..]); + // Loose objects are written read-only by git — reclaim write permission before + // clobbering the bytes, or the write itself fails with EACCES. + let mut perms = std::fs::metadata(&object_path).unwrap().permissions(); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&object_path, perms).unwrap(); + std::fs::write(&object_path, b"garbage-not-a-git-object\n").unwrap(); + + app.refresh(); + + assert!( + !app.is_current_pending(), + "a sync diff failure sets Failed, not Pending — nothing async is retrying this" + ); + assert!( + app.current_failure().is_some(), + "the uncommitted layer's failed sync re-diff must become a Failed slot" + ); + let notice = app + .notice + .as_ref() + .expect("a failed uncommitted sync re-diff must set a footer notice"); + assert_eq!(notice.severity, Severity::Error); + assert!( + notice.text.contains("uncommitted diff failed"), + "got notice text: {:?}", + notice.text + ); + assert!(app.take_pending_wave().is_none()); + } + // ---- M4 index watcher (`on_tick`) ------------------------------------------------------- /// Stage `path` in the fixture's index, exactly as an external `git add` would — the write diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index e497c92..75d4e64 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -18,7 +18,7 @@ use std::fs::File; use std::io::{self, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::thread; use std::time::Duration; @@ -229,13 +229,23 @@ fn spawn_loader_thread( req_tx } -/// Spawn the ADR-031 startup wave: stripe `changesets` (lib-`current` first, then input order) -/// across `available_parallelism`-many transient WORKER threads — same fan-out shape as +/// Spawn a ADR-031 diff wave — the startup wave over the whole resolved stack, or (ADR-031 +/// "Refresh") a refresh's span-keyed reuse leftovers, the changed/new committed spans +/// [`workon_review::app::App::take_pending_wave`] queued. Stripes `to_diff` (`current_idx`-first +/// if the active changeset is among the pairs being diffed, then input order) across +/// `available_parallelism`-many transient WORKER threads — same fan-out shape as /// `crate::acquire::diff_changesets` (each worker opens its own `Repository`, since /// `git2::Repository` is `Send` but not `Sync`) — but STREAM each result the instant it completes /// via `tx` rather than joining the batch. Never joined itself either — a wave straggler left -/// running past quit is harmless (it only ever sends into an inbox nothing is listening to -/// anymore; `tx.send` failing is the signal each worker already checks). +/// running past quit (or superseded by a later refresh's generation) is harmless: it only ever +/// sends into an inbox nothing is listening to anymore, or a result [`App::apply_changeset_ready`] +/// drops outright on a generation mismatch; `tx.send` failing is the signal each worker already +/// checks for the former. +/// +/// `to_diff`'s `usize` is the pair's index into `App`'s FULL changeset stack (not a position +/// within `to_diff` itself) — carried straight through to each `ChangesetReady { idx, .. }` so +/// [`App::apply_changeset_ready`] can seat the result without `App` and this wave ever agreeing +/// on a separate numbering. /// /// A DELIBERATELY separate set of threads from the loader thread (ADR-031 leaves this shape /// open — "yours to shape"): the wave never touches the loader's request queue, so an in-flight @@ -252,20 +262,22 @@ fn spawn_loader_thread( fn spawn_wave_thread( repo_path: PathBuf, tx: mpsc::Sender, - changesets: Vec, + to_diff: Vec<(usize, Changeset)>, gen: u64, + current_idx: Option, ) { thread::spawn(move || { - let n = changesets.len(); + let n = to_diff.len(); if n == 0 { return; } - // Current changeset first, then input order for the rest — the changeset the user lands - // on becomes interactive earliest (ADR-031's "Slots"). - let current_idx = changesets.iter().position(|cs| cs.current); + // The active changeset first (if it's among these pairs at all), then input order for + // the rest — the changeset the user lands on becomes interactive earliest (ADR-031's + // "Slots"). `current_pos` is a position WITHIN `to_diff`, not the stack index itself. + let current_pos = current_idx.and_then(|ci| to_diff.iter().position(|(idx, _)| *idx == ci)); let mut order: Vec = Vec::with_capacity(n); - order.extend(current_idx); - order.extend((0..n).filter(|&i| Some(i) != current_idx)); + order.extend(current_pos); + order.extend((0..n).filter(|&i| Some(i) != current_pos)); let workers = thread::available_parallelism() .map(std::num::NonZeroUsize::get) @@ -274,20 +286,21 @@ fn spawn_wave_thread( let chunk = n.div_ceil(workers.max(1)); thread::scope(|scope| { - for idx_chunk in order.chunks(chunk) { + for pos_chunk in order.chunks(chunk) { let tx = tx.clone(); - let changesets = &changesets; + let to_diff = &to_diff; let repo_path = &repo_path; scope.spawn(move || { let repo = match Repository::open(repo_path) { Ok(repo) => repo, Err(err) => { let message = err.to_string(); - for &idx in idx_chunk { + for &pos in pos_chunk { + let (idx, _) = &to_diff[pos]; if tx .send(Ok(AppEvent::ChangesetReady { gen, - idx, + idx: *idx, result: Err(message.clone()), })) .is_err() @@ -298,11 +311,15 @@ fn spawn_wave_thread( return; } }; - for &idx in idx_chunk { - let result = - diff_changeset(&repo, &changesets[idx]).map_err(|e| e.to_string()); + for &pos in pos_chunk { + let (idx, cs) = &to_diff[pos]; + let result = diff_changeset(&repo, cs).map_err(|e| e.to_string()); if tx - .send(Ok(AppEvent::ChangesetReady { gen, idx, result })) + .send(Ok(AppEvent::ChangesetReady { + gen, + idx: *idx, + result, + })) .is_err() { return; // main loop is gone; nothing left to forward to @@ -314,6 +331,20 @@ fn spawn_wave_thread( }); } +/// The ADR-031 pipeline handles [`event_loop`] needs to dispatch off-thread work — bundled into +/// one struct (rather than four separate parameters) so `event_loop` stays under clippy's +/// `too_many_arguments`. `inbox` is the single shared receiver; `load_tx`/`wave_tx` dispatch to +/// the loader thread and a fresh diff-wave thread respectively; `repo_path` is what any +/// newly-spawned wave thread opens its own `Repository` handle against (a refresh can queue a +/// wave well after startup, so this is kept around for the whole loop, not just its setup). +#[derive(Clone, Copy)] +struct Pipeline<'a> { + inbox: &'a mpsc::Receiver, + load_tx: &'a mpsc::Sender, + wave_tx: &'a mpsc::Sender, + repo_path: &'a Path, +} + /// Receive the next event from `inbox`, waiting up to `timeout`. A timeout with nothing received /// yields `Ok(AppEvent::Tick)` — the loop's regular redraw beat, and the mechanism the M4 index /// watcher polls on (see the module doc). A disconnected inbox (the input thread panicked, or @@ -885,8 +916,14 @@ impl Tui { ) -> io::Result<()> { let (tx, rx) = mpsc::channel::(); spawn_input_thread(tx.clone()); - let load_tx = spawn_loader_thread(repo_path, tx); - let result = event_loop(&mut self.terminal, app, keymap, theme, &rx, &load_tx); + let load_tx = spawn_loader_thread(repo_path.clone(), tx.clone()); + let pipeline = Pipeline { + inbox: &rx, + load_tx: &load_tx, + wave_tx: &tx, + repo_path: &repo_path, + }; + let result = event_loop(&mut self.terminal, app, keymap, theme, &pipeline); let restored = self.restore(); result.and(restored) } @@ -912,8 +949,24 @@ impl Tui { let (tx, rx) = mpsc::channel::(); spawn_input_thread(tx.clone()); let load_tx = spawn_loader_thread(repo_path.clone(), tx.clone()); - spawn_wave_thread(repo_path, tx, changesets, app.generation()); - let result = event_loop(&mut self.terminal, app, keymap, theme, &rx, &load_tx); + // `App::from_changesets` (which built `app`'s all-`Pending` slots) picked `current_cs` + // via the same lib-`current` lookup this enumeration mirrors, so `app.current_cs()` IS + // that changeset's index into `to_diff` here — no separate lookup needed. + let to_diff: Vec<(usize, Changeset)> = changesets.into_iter().enumerate().collect(); + spawn_wave_thread( + repo_path.clone(), + tx.clone(), + to_diff, + app.generation(), + Some(app.current_cs()), + ); + let pipeline = Pipeline { + inbox: &rx, + load_tx: &load_tx, + wave_tx: &tx, + repo_path: &repo_path, + }; + let result = event_loop(&mut self.terminal, app, keymap, theme, &pipeline); let restored = self.restore(); result.and(restored) } @@ -961,9 +1014,14 @@ fn event_loop( app: &mut App, keymap: &Keymap, theme: &Palette, - inbox: &mpsc::Receiver, - load_tx: &mpsc::Sender, + pipeline: &Pipeline<'_>, ) -> io::Result<()> { + let Pipeline { + inbox, + load_tx, + wave_tx, + repo_path, + } = *pipeline; let mut pending: Vec = Vec::new(); let mut quit = false; @@ -1006,6 +1064,22 @@ fn event_loop( let mut batch = vec![event]; drain_pending(inbox, &mut batch)?; quit = update_batch(app, keymap, &mut pending, batch); + + // ADR-031 "Refresh": every refresh trigger (`r`, the on-tick index watcher, a + // post-staging drain) runs through `App::refresh` somewhere inside the `update_batch` + // call above, however deeply nested — `App` itself never touches a thread, so it just + // queues the span-keyed-reuse leftovers on `Self::pending_wave` for whoever's holding the + // thread-spawning ability to pick up. This ONE checkpoint, run after every batch, is that + // pickup: it covers every refresh trigger uniformly with no per-trigger wiring. + if let Some((gen, to_diff)) = app.take_pending_wave() { + spawn_wave_thread( + repo_path.to_path_buf(), + wave_tx.clone(), + to_diff, + gen, + Some(app.current_cs()), + ); + } } } From b98b5f44c95427bb2afaf7818dff1e945c32f395 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 10:13:56 -0400 Subject: [PATCH 094/203] fix(review): clear stale deferred-open flags on non-deferred opens --- git-workon-review/src/app.rs | 77 ++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index e0e98d8..448bc06 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1664,6 +1664,15 @@ impl App { } self.ensure_loaded(self.current); self.reset_panes(); + // F1: an eager load or the empty-file no-op above supersedes any STALE deferred open — + // e.g. a pending open set before `r`, followed by a refresh that turns the active + // changeset `Pending` (empty files, skipping the defer branch above since there's + // nothing to load). Without this, the stale flags survive the refresh and wedge the + // idle-Tick fast-poll loop (`take_pending_load_spec` keeps returning `None` for a file + // that no longer exists) while the placeholder stays stuck. Mirrors + // [`Self::complete_pending_open`]'s tail. + self.open_pending = false; + self.open_pending_dispatched = false; } /// Whether the view(s) the current file's effective zoom needs are already cached, making a @@ -5052,6 +5061,74 @@ mod tests { ); } + #[test] + fn refresh_that_makes_the_active_changeset_pending_clears_stale_deferred_open_flags() { + // F1 regression: a pending open still in flight (dispatched, awaiting a loader result) + // right before `r`, followed by a refresh that turns the active changeset Pending (a + // changed span goes through the wave, landing empty files) must not leave + // `open_pending`/`open_pending_dispatched` wedged. `open_current`'s empty-file guard + // skips the defer branch (nothing to load) after the refresh, so nothing else would + // clear stale flags without this fix. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let (root, old_head) = root_and_head_commits(&fixture); + let repo = fixture.repo().unwrap(); + + let cs = Changeset { + name: format!("{root}..main"), + span: ChangesetSpan::Committed { + base: root, + head: old_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.set_review_source(crate::source::Source::Range { + base_text: root.to_string(), + head_text: "main".to_string(), + dots: crate::source::RangeDots::Two, + }); + app.set_defer_loads(true); + app.open_current(); + let _ = app + .take_pending_load_spec() + .expect("a fresh pending open dispatches"); + assert!(app.open_pending(), "the open is pending, awaiting a result"); + + // Advance `main`, changing the span — refresh must send this through the wave, landing + // the active changeset Pending (empty files) rather than reusing the stale slot. + fixture + .commit("main") + .file("c.txt", "c\n") + .create("new head") + .unwrap(); + + app.refresh(); + + assert!( + app.is_current_pending(), + "a changed span must go Pending for the wave" + ); + assert!(app.files().is_empty()); + assert!( + !app.open_pending(), + "a stale deferred open must not survive a refresh that empties the active changeset" + ); + assert!( + app.take_pending_load_spec().is_none(), + "no load spec can be produced for a Pending slot with no files" + ); + } + #[test] fn refresh_retries_a_failed_committed_slot_via_a_wave_rather_than_reusing_it() { let fixture = FixtureBuilder::new() From 5036ca3fb1e8d136b988ab19f1cd9143a03e31ff Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 10:44:14 -0400 Subject: [PATCH 095/203] test(review): fileless open pins cleared flags after flag hygiene --- git-workon-review/src/app.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 448bc06..acf167c 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -3758,9 +3758,12 @@ mod tests { #[test] fn take_pending_load_spec_is_none_for_a_fileless_changeset_without_panicking() { - // A clean uncommitted layer diffs to zero files. A pending open onto it (e.g. one that - // outraces a refresh, or the Pending/Failed slots ADR-031's later changesets introduce) - // must not panic `current_load_spec`'s file-list indexing — F7's regression. + // A clean uncommitted layer diffs to zero files. A pending open onto it must not panic + // `current_load_spec`'s file-list indexing — F7's regression. Where this test was born + // (the loader changeset), a fileless `open_current` still MARKED the open pending and + // only the spec-building was total; this changeset's flag hygiene supersedes that — + // a fileless open now never marks (and actively clears) `open_pending`, so both the + // flag and the spec must come back empty. let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .build() @@ -3770,7 +3773,10 @@ mod tests { assert!(app.files().is_empty(), "fixture must have no diffed files"); app.set_defer_loads(true); app.open_current(); - assert!(app.open_pending(), "empty-file open still marks pending"); + assert!( + !app.open_pending(), + "a fileless open never marks pending (the non-deferred path clears the flags)" + ); assert!( app.take_pending_load_spec().is_none(), From 9d7bf69a5443bb891ca47db44c66aa5ab8116858 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 03:31:45 -0400 Subject: [PATCH 096/203] test(review): real-thread pipeline smoke and streamed-launch oracle --- git-workon-review/src/tui.rs | 194 ++++++++++++++++++ git-workon-review/tests/pty_responsiveness.rs | 177 +++++++++++++++- 2 files changed, 369 insertions(+), 2 deletions(-) diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 75d4e64..0b61efd 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -2599,4 +2599,198 @@ mod tests { "splash frame must show the launch-activity message, got: {top_row:?}" ); } + + // ── ADR-031: real-thread integration smoke ───────────────────────────────── + + /// ADR-031's Testing layer 3, part one: the ONE test in this module that spawns the REAL + /// [`spawn_loader_thread`]/[`spawn_wave_thread`] against real `mpsc` channels — everything + /// else in this file drives `update`/`update_batch` with synthetic events specifically to + /// avoid real threads (see the ADR's "Testing" decision: layers 1-2 are thread-free by + /// design). This is the one exception, confined here. + /// + /// Mirrors `main.rs`'s streamed-launch shape exactly: `App::from_changesets` over + /// all-`Pending` slots, `set_defer_loads(true)`, `open_current()`, then the same + /// `spawn_wave_thread` call `Tui::run_streamed` makes. From there this test plays the event + /// loop's OWN role by hand — draining the shared inbox and routing `ChangesetReady`/ + /// `FileReady` through the exact chokepoints `update`'s match arms call + /// (`App::apply_changeset_ready`/`App::apply_file_ready`), plus the same post-batch + /// `take_pending_load_spec` dispatch `event_loop` runs on every idle tick while an open is + /// pending — except here it's driven the instant the active changeset seats, not gated behind + /// a real debounce `Tick`, since nothing here is racing real terminal input. + /// + /// Bounded by `recv_timeout` per receive (not a wall-clock test deadline): a wedged thread + /// times out and fails loudly rather than hanging the suite, but a healthy run's actual + /// duration is however long the real diff/load work takes — no sleeping, no fixed budget, so + /// this stays load-tolerant enough to run unconditionally (unlike `pty_responsiveness.rs`'s + /// `#[ignore]` siblings, which assert actual elapsed wall-clock time). + #[test] + fn real_threads_stream_a_wave_and_complete_a_deferred_file_open() { + use git_workon_fixture::prelude::*; + use workon::{Changeset, ChangesetSpan}; + use workon_review::app::ChangesetView; + + // A 4-changeset committed stack, each adding one file — the streamed-launch "multi- + // changeset fixture stack" the plan calls for, deep enough that the wave has real + // fan-out work (`spawn_wave_thread` stripes across `available_parallelism` workers) and + // that the ACTIVE (last, `current: true`) changeset has a real, uncached file for the + // deferred-open assertion. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let root = fixture + .commit("main") + .file("root.txt", "r\n") + .create("root") + .unwrap(); + let c1 = fixture + .commit("main") + .file("a.txt", "a\n") + .create("c1") + .unwrap(); + let c2 = fixture + .commit("main") + .file("b.txt", "b\n") + .create("c2") + .unwrap(); + let c3 = fixture + .commit("main") + .file("c.txt", "c\n") + .create("c3") + .unwrap(); + let c4 = fixture + .commit("main") + .file("d.txt", "d\n") + .create("c4") + .unwrap(); + + let bare = |name: &str, base, head, current| Changeset { + name: name.to_string(), + span: ChangesetSpan::Committed { base, head }, + title: None, + current, + needs_restack: false, + }; + let changesets = vec![ + bare("cs-1", root, c1, false), + bare("cs-2", c1, c2, false), + bare("cs-3", c2, c3, false), + bare("cs-4", c3, c4, true), + ]; + + let repo = fixture.repo().unwrap(); + let repo_path = repo.workdir().unwrap().to_path_buf(); + let owned = Repository::open(&repo_path).unwrap(); + + let pending_views: Vec = changesets + .iter() + .cloned() + .map(ChangesetView::pending) + .collect(); + let mut app = App::from_changesets(owned, pending_views); + app.set_defer_loads(true); + app.open_current(); + + let active_idx = app.current_cs(); + assert_eq!( + active_idx, 3, + "the lib-current changeset (cs-4) opens active" + ); + assert!(app.is_current_pending(), "every slot starts Pending"); + + let (tx, rx) = mpsc::channel::(); + let load_tx = spawn_loader_thread(repo_path.clone(), tx.clone()); + let to_diff: Vec<(usize, Changeset)> = changesets.into_iter().enumerate().collect(); + spawn_wave_thread( + repo_path.clone(), + tx.clone(), + to_diff, + app.generation(), + Some(active_idx), + ); + drop(tx); // this test's only senders now are the two spawned threads + + let deadline_per_recv = Duration::from_secs(15); + let mut changesets_ready = vec![false; app.changeset_count()]; + let mut active_file_loaded = false; + let mut dispatched_active_load = false; + + loop { + if changesets_ready.iter().all(|&r| r) && active_file_loaded { + break; + } + let event = rx + .recv_timeout(deadline_per_recv) + .expect("loader/wave thread must answer within the deadline") + .expect("neither real thread should forward a read error in this test"); + + match event { + AppEvent::ChangesetReady { gen, idx, result } => { + assert!( + result.is_ok(), + "a committed changeset diff must not fail here" + ); + app.apply_changeset_ready(gen, idx, result); + changesets_ready[idx] = true; + } + AppEvent::FileReady { + gen, + cs_idx, + file_idx, + result, + } => { + assert!(result.is_ok(), "a real file load must not fail here"); + app.apply_file_ready(gen, cs_idx, file_idx, result); + if cs_idx == active_idx && file_idx == 0 { + active_file_loaded = true; + } + } + other => panic!("unexpected event in the real-thread smoke: {other:?}"), + } + + // The same post-batch checkpoint `event_loop` runs on every idle `Tick` while an + // open is pending — dispatched here the instant it's possible (right after the + // active changeset seats) rather than gated behind a real debounce, since nothing in + // this test races real terminal input. + if !dispatched_active_load && app.open_pending() { + if let Some((gen, cs_idx, file_idx, spec)) = app.take_pending_load_spec() { + load_tx + .send(LoadRequest { + gen, + cs_idx, + file_idx, + spec, + }) + .expect("loader thread must still be alive to receive the dispatch"); + dispatched_active_load = true; + } + } + } + + assert!( + changesets_ready.iter().all(|&r| r), + "every slot must land Ready: {changesets_ready:?}" + ); + assert!( + !app.is_current_pending(), + "the active changeset must be seated once its wave result lands" + ); + assert_eq!( + app.current_cs(), + active_idx, + "seating must not move which changeset is active" + ); + assert!( + active_file_loaded, + "the active changeset's deferred file open must complete via a real FileReady" + ); + assert!( + !app.open_pending(), + "a completed FileReady must clear the pending-open flag" + ); + assert!( + app.current_view_ref().is_some(), + "the active file's view must be cached after its FileReady lands" + ); + } } diff --git a/git-workon-review/tests/pty_responsiveness.rs b/git-workon-review/tests/pty_responsiveness.rs index a1b0fcd..41731d6 100644 --- a/git-workon-review/tests/pty_responsiveness.rs +++ b/git-workon-review/tests/pty_responsiveness.rs @@ -1,5 +1,6 @@ -//! PTY responsiveness smoke tests for the 2026-07 performance pass — the launch path and the -//! rapid-outline-nav path, driven against the real binary in a pseudo-terminal. +//! PTY responsiveness smoke tests for the 2026-07 performance pass — the launch path, the +//! rapid-outline-nav path, and (ADR-031) the streamed-startup path, driven against the real +//! binary in a pseudo-terminal. //! //! These guard the *regression classes* that pass fixed, not the milliseconds it measured: //! @@ -13,6 +14,11 @@ //! burst→quit; if input coalescing (`update_batch`) or idle-deferred loads //! (`open_pending`/`OPEN_DEBOUNCE`) regress, the quit waits behind the sum of every //! intermediate file's load and blows the bound. +//! - **Streamed startup (ADR-031):** before the progressive pipeline, a multi-changeset launch +//! diffed the WHOLE stack sequentially-then-in-parallel before the first frame ever drew — +//! the wait scaled with stack depth. The streamed-startup test bounds spawn→quit on a deep +//! stack so a reintroduced "wait for the full wave" launch fails loudly; see that test's doc +//! comment for the measured before/after numbers that sized its bound. //! //! The bounds are deliberately blunt (seconds, not milliseconds): absolute wall-clock //! assertions flake under parallel CPU load, exactly like git-workon's @@ -63,6 +69,37 @@ const BURST_FILES: usize = 36; /// Lines per generated fixture file — see `BURST_FILES`. const BURST_FILE_LINES: usize = 2_000; +/// Upper bound on spawn→quit for `streamed_startup_lands_before_a_full_wave_could_have_finished` +/// (ADR-031). Sized from real measurements on this fixture (debug build, `q` sent right after +/// alternate-screen entry — same protocol as `LAUNCH_RESPONSIVE`, so `q` buffers in the PTY +/// until the event loop actually starts polling input): +/// +/// - The CURRENT (streamed) binary: ~1.1-1.4s — dominated by fixed per-launch costs +/// (`gt --version`'s ~350ms subprocess spawn, checking out `STREAMED_STACK_SIZE` branches' +/// worth of files) plus ONE changeset's diff (the active one, streamed first). +/// - The PRE-ADR-031 binary (commit `2214558`, built and run against the identical fixture): +/// ~6.4s — it blocks on the full stack's diff wave before the event loop ever starts, so `q` +/// sits in the PTY the whole time. +/// +/// This bound sits roughly 2× above the healthy number and comfortably (~2×) below the +/// regressed one — the same blunt, load-tolerant margin philosophy as `LAUNCH_RESPONSIVE`/ +/// `BURST_RESPONSIVE`, just re-measured for this fixture rather than reused, since the streamed +/// path's fixed costs (checkout of a much deeper stack) differ from the single-changeset launch +/// test's. +const STREAMED_STARTUP_RESPONSIVE: Duration = Duration::from_secs(3); + +/// Depth of the Graphite stack `streamed_startup_lands_before_a_full_wave_could_have_finished` +/// builds — deep and wide enough (with `STREAMED_STARTUP_FILE_LINES`) that the full wave's +/// total diff cost is many seconds, well clear of `STREAMED_STARTUP_RESPONSIVE`, while a single +/// changeset's diff (what the streamed path actually waits on before its first frame) stays +/// well under it. See `STREAMED_STARTUP_RESPONSIVE`'s doc comment for the measurements that +/// picked this size. +const STREAMED_STARTUP_STACK_SIZE: usize = 150; + +/// Lines per generated fixture file in the streamed-startup stack — see +/// `STREAMED_STARTUP_STACK_SIZE`. +const STREAMED_STARTUP_FILE_LINES: usize = 3_000; + /// A plausible-enough Rust source of ~`lines` lines, distinct per `seed`, so the tree-sitter /// highlighter has real parsing work per file (the regression cost being guarded). fn rust_source(seed: usize, lines: usize) -> String { @@ -153,3 +190,139 @@ fn rapid_outline_nav_burst_stays_responsive() { (input coalescing or idle-deferred loads regressed)" ); } + +/// Commit `path`/`content` as a child of `parent`, without moving any branch ref — mirrors +/// `diff_model.rs`'s `commit_onto`, duplicated here rather than shared: this file needs it only +/// to build one deep real Graphite chain, not worth a cross-test-file dependency for. +fn commit_onto( + repo: &git2::Repository, + parent: &git2::Commit, + path: &str, + content: &str, +) -> git2::Oid { + let mut treebuilder = repo.treebuilder(Some(&parent.tree().unwrap())).unwrap(); + let blob_oid = repo.blob(content.as_bytes()).unwrap(); + treebuilder + .insert(path, blob_oid, git2::FileMode::Blob.into()) + .unwrap(); + let tree_oid = treebuilder.write().unwrap(); + let tree = repo.find_tree(tree_oid).unwrap(); + let sig = repo.signature().unwrap(); + repo.commit(None, &sig, &sig, "test commit", &tree, &[parent]) + .unwrap() +} + +/// ADR-031's Testing layer 3, part two: the `pty_responsiveness` extension the ADR calls for — +/// "asserting the first interactive frame lands before a full wave could have finished". A real +/// `STREAMED_STARTUP_STACK_SIZE`-deep Graphite stack, each node adding one +/// `STREAMED_STARTUP_FILE_LINES`-line file (real content, so each changeset's diff is real, +/// non-trivial work — the cost being guarded), checked out on the TIP branch so the real +/// binary's no-argument auto-detect (`StackModel::detect` + `assemble_changesets`) sees the +/// whole stack, exactly like a real multi-changeset Graphite review. +/// +/// The oracle is the SAME shape as `launch_reaches_the_tui_and_quits_promptly`'s: `q` sent the +/// instant the alternate screen appears, bounding spawn→quit — `q` buffers in the PTY until the +/// event loop starts polling input, so this elapsed time IS "time until the event loop is +/// actually running and responsive", not just "time to first draw". That is precisely what a +/// de-streaming regression breaks: reverting `Tui::run_streamed` to block on the full diff wave +/// (e.g. joining `spawn_wave_thread`, or calling `diff_changesets` synchronously like the +/// pre-ADR-031 multi-changeset path did) delays the event loop's first `recv_event` by the +/// WHOLE wave's cost, not just the active changeset's — so `q` sits unanswered in the PTY for +/// the full wave's duration. See `STREAMED_STARTUP_RESPONSIVE`'s doc comment for the actual +/// measured numbers (streamed ~1.1-1.4s, reverted-to-pre-ADR-031 ~6.4s on this exact fixture) +/// 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"] +fn streamed_startup_lands_before_a_full_wave_could_have_finished() { + use workon::{assemble_changesets, StackModel, UncommittedLayer}; + + let n = STREAMED_STARTUP_STACK_SIZE; + let mut builder = FixtureBuilder::new() + .config("core.autocrlf", "false") + .config("workon.review.theme", "dark") + .graphite_config(&["main"]); + for i in 0..n { + let parent = if i == 0 { + "main".to_string() + } else { + format!("cs{}", i - 1) + }; + builder = builder.branch_metadata(&format!("cs{i}"), &parent); + } + let fixture = builder.build().expect("fixture"); + let repo = fixture.repo().expect("fixture repo"); + + // `branch_metadata` creates each branch ref at build() time (co-located with `main`'s tip); + // advance each one to a REAL, distinct commit forming an actual linear chain — same pattern + // `diff_model.rs`'s Graphite tests use. + let main_tip = repo + .find_branch("main", git2::BranchType::Local) + .expect("main branch") + .get() + .target() + .expect("main tip"); + let mut parent_oid = main_tip; + for i in 0..n { + let parent_commit = repo.find_commit(parent_oid).expect("parent commit"); + let head = commit_onto( + repo, + &parent_commit, + &format!("f{i}.rs"), + &rust_source(i, STREAMED_STARTUP_FILE_LINES), + ); + fixture + .update_branch(&format!("cs{i}"), head) + .expect("advance branch"); + parent_oid = head; + } + + // Sanity: the stack really does resolve to `n` real changesets, each with a real diff to + // do — a fixture bug here (e.g. all branches landing on the same commit) would make the + // bound pass for the wrong reason. + let changesets = assemble_changesets( + repo, + &format!("cs{}", n - 1), + StackModel::Graphite, + UncommittedLayer::Include, + ) + .expect("assemble the real Graphite stack"); + assert_eq!( + changesets + .iter() + .filter(|c| c.name.starts_with("cs")) + .count(), + n, + "fixture setup must produce every tracked changeset" + ); + + // Check out the tip branch as HEAD — the real binary's no-argument auto-detect reads + // `repo.head()`'s shorthand, not an explicit `[SOURCE]` argument. + repo.set_head(&format!("refs/heads/cs{}", n - 1)) + .expect("set HEAD to the tip branch"); + let mut checkout = git2::build::CheckoutBuilder::new(); + checkout.force(); + repo.checkout_head(Some(&mut checkout)) + .expect("checkout the tip branch"); + + let launched = Instant::now(); + let mut session = spawn_review(&fixture); + session + .expect("\x1b[?1049h") + .expect("TUI entered the alternate screen"); + + // Same protocol as the launch test: `q` buffers in the PTY until the event loop polls + // input, so spawn→quit IS time-to-interactive plus one quit — the full wave's cost, if the + // event loop were blocked behind it, lands entirely inside this window. + session.send("q").expect("send q"); + session.expect(expectrl::Eof).expect("app exited on q"); + + let elapsed = launched.elapsed(); + eprintln!("streamed startup ({n} changesets)→quit: {elapsed:?}"); + assert!( + elapsed < STREAMED_STARTUP_RESPONSIVE, + "streamed-startup→quit took {elapsed:?} on a {n}-changeset stack — the event loop \ + looks blocked behind the full diff wave again (streamed launch regressed to a \ + synchronous wait)" + ); +} From 3d6d0d70e92ee4c4094bd597667930693fd105a3 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 10:28:20 -0400 Subject: [PATCH 097/203] test(review): pin per-changeset spans in the streamed-startup fixture --- git-workon-review/tests/pty_responsiveness.rs | 65 +++++++++++++++---- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/git-workon-review/tests/pty_responsiveness.rs b/git-workon-review/tests/pty_responsiveness.rs index 41731d6..851054a 100644 --- a/git-workon-review/tests/pty_responsiveness.rs +++ b/git-workon-review/tests/pty_responsiveness.rs @@ -70,19 +70,27 @@ const BURST_FILES: usize = 36; const BURST_FILE_LINES: usize = 2_000; /// Upper bound on spawn→quit for `streamed_startup_lands_before_a_full_wave_could_have_finished` -/// (ADR-031). Sized from real measurements on this fixture (debug build, `q` sent right after +/// (ADR-031). Sized from real measurements on this fixture (`q` sent right after /// alternate-screen entry — same protocol as `LAUNCH_RESPONSIVE`, so `q` buffers in the PTY /// until the event loop actually starts polling input): /// -/// - The CURRENT (streamed) binary: ~1.1-1.4s — dominated by fixed per-launch costs -/// (`gt --version`'s ~350ms subprocess spawn, checking out `STREAMED_STACK_SIZE` branches' -/// worth of files) plus ONE changeset's diff (the active one, streamed first). -/// - The PRE-ADR-031 binary (commit `2214558`, built and run against the identical fixture): -/// ~6.4s — it blocks on the full stack's diff wave before the event loop ever starts, so `q` -/// sits in the PTY the whole time. +/// - The CURRENT (streamed) binary: ~1.0-1.3s (debug build; ~1.0s release) — dominated by fixed +/// per-launch costs (`gt --version`'s ~350ms subprocess spawn, checking out +/// `STREAMED_STARTUP_STACK_SIZE` branches' worth of files) plus entering the alternate screen +/// and starting the event loop, which — this is the whole point of ADR-031 — happens BEFORE +/// any changeset is diffed: the wave runs on a background thread, off the spawn→interactive +/// critical path entirely. Re-measured after fixing the fixture setup (F4) that was building +/// every `cs{i}`'s base against main's tip instead of its own predecessor's head; since none +/// of a changeset's diff cost is on this critical path either way, the number barely moved — +/// confirming spawn→quit here really is a fixed-cost bound, not a diff-size one. +/// - The PRE-ADR-031 binary (commit `2214558`, built and run against an equivalent fixture +/// pre-F4): ~6.4s — it blocks on the full stack's diff wave before the event loop ever starts, +/// so `q` sits in the PTY the whole time. Not re-measured against the F4-corrected fixture +/// (would need rebuilding that historical commit); a synchronous full-wave wait over 150 +/// real per-changeset diffs is unambiguously far past this bound either way. /// -/// This bound sits roughly 2× above the healthy number and comfortably (~2×) below the -/// regressed one — the same blunt, load-tolerant margin philosophy as `LAUNCH_RESPONSIVE`/ +/// This bound sits comfortably (~2-3×) above the healthy number and well below the regressed +/// one — the same blunt, load-tolerant margin philosophy as `LAUNCH_RESPONSIVE`/ /// `BURST_RESPONSIVE`, just re-measured for this fixture rather than reused, since the streamed /// path's fixed costs (checkout of a much deeper stack) differ from the single-changeset launch /// test's. @@ -287,14 +295,45 @@ fn streamed_startup_lands_before_a_full_wave_could_have_finished() { UncommittedLayer::Include, ) .expect("assemble the real Graphite stack"); + let cs_changesets: Vec<&workon::Changeset> = changesets + .iter() + .filter(|c| c.name.starts_with("cs")) + .collect(); assert_eq!( - changesets - .iter() - .filter(|c| c.name.starts_with("cs")) - .count(), + cs_changesets.len(), n, "fixture setup must produce every tracked changeset" ); + // F4: pin the intended shape — each `cs{i}`'s span is base→head against its OWN immediate + // predecessor (cs0's base is main's tip), not every changeset cumulatively based on main. + // `branch_metadata`'s revisions resolve once at `build()` time (before the `update_branch` + // loop above moves any ref), so a fixture that doesn't keep those recorded revisions in + // sync makes `resolve_graphite_base` fall back to main's tip for every one of them — + // cumulative 150-file spans and `needs_restack = true` everywhere, contradicting both this + // shape and the "one changeset's diff" cost this test's bound is sized against. + let mut expected_base = main_tip; + for (i, cs) in cs_changesets.iter().enumerate() { + assert_eq!( + cs.name, + format!("cs{i}"), + "changesets must come back in base→head order" + ); + match cs.span { + workon::ChangesetSpan::Committed { base, head } => { + assert_eq!( + base, expected_base, + "cs{i}'s base must be its own predecessor's head, not main's tip" + ); + expected_base = head; + } + other => panic!("cs{i} must be a Committed span, got {other:?}"), + } + assert!( + !cs.needs_restack, + "cs{i} must not need a restack — its recorded parent revision must track its \ + parent's live tip" + ); + } // Check out the tip branch as HEAD — the real binary's no-argument auto-detect reads // `repo.head()`'s shorthand, not an explicit `[SOURCE]` argument. From f68218524582b110154aaaba3904072e46dabc85 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 12:54:22 -0400 Subject: [PATCH 098/203] perf(review): cache silent-terminal probe verdicts for theme auto --- Cargo.lock | 2 + Cargo.toml | 1 + git-workon-review/Cargo.toml | 2 + git-workon-review/src/lib.rs | 1 + git-workon-review/src/main.rs | 12 +- git-workon-review/src/probe_cache.rs | 332 +++++++++++++++++++++ git-workon-review/src/terminal_query.rs | 89 +++++- git-workon-review/tests/pty_smoke.rs | 32 +- git-workon-review/tests/pty_support/mod.rs | 15 +- 9 files changed, 462 insertions(+), 24 deletions(-) create mode 100644 git-workon-review/src/probe_cache.rs diff --git a/Cargo.lock b/Cargo.lock index 6ee3119..bcf1f3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -964,6 +964,7 @@ dependencies = [ "clap", "clap_complete", "crossterm", + "dirs", "expectrl", "git-workon-fixture", "git-workon-lib", @@ -972,6 +973,7 @@ dependencies = [ "miette", "predicates", "ratatui", + "serde_json", "similar", "thiserror 2.0.19", "tree-sitter", diff --git a/Cargo.toml b/Cargo.toml index cf70bc7..34e4346 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ clap_complete = { version = "4.6.5", features = ["unstable-dynamic"] } clap_mangen = "0.3.0" crossterm = "0.29.0" dialoguer = { version = "0.12.0", features = ["fuzzy-select"] } +dirs = "6.0" env_logger = "0.11.10" git-workon-lib = { version = "0.11.0", path = "./git-workon-lib" } git-workon-fixture = { path = "./git-workon-fixture" } diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index 6c4e353..32bc7e9 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -35,11 +35,13 @@ vendored = ["git-workon-lib/vendored", "git2/vendored-libgit2", "git2/vendored-o clap.workspace = true clap_complete.workspace = true crossterm.workspace = true +dirs.workspace = true git-workon-lib.workspace = true git2.workspace = true libc.workspace = true miette.workspace = true ratatui.workspace = true +serde_json.workspace = true similar.workspace = true thiserror.workspace = true tree-sitter.workspace = true diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index b45d452..cff5f73 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -25,6 +25,7 @@ pub mod keymap; pub mod model; pub mod ops; pub mod outline; +pub mod probe_cache; pub mod queue; pub mod refresh; pub mod render; diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 6150097..4e7fa06 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -94,12 +94,16 @@ fn main() -> Result<()> { // 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. let selection = ReviewConfig::new(&repo).theme(); - let probed = matches!(selection, Ok(config::Theme::Auto)); - let theme = match selection { + let (theme, probed) = match selection { Ok(config::Theme::Auto) => terminal_query::detect_auto_palette(), - Ok(selection) => Palette::for_theme(selection), - Err(_) => Palette::dark(), + Ok(selection) => (Palette::for_theme(selection), false), + Err(_) => (Palette::dark(), false), }; // Resolve the view-config settings (outline width/mode, diff layout/zoom) the same way, diff --git a/git-workon-review/src/probe_cache.rs b/git-workon-review/src/probe_cache.rs new file mode 100644 index 0000000..58e24d8 --- /dev/null +++ b/git-workon-review/src/probe_cache.rs @@ -0,0 +1,332 @@ +//! Cache for the `theme = auto` probe's "this terminal never answers" verdict (a follow-up to +//! ADR-031, whose scope section deferred it). +//! +//! [`crate::terminal_query::detect_auto_palette`] pays an 800ms timeout ONLY when the terminal +//! answers nothing at all — every real interactive terminal answers within a few ms, so a full +//! timeout means the controlling terminal (tmux without passthrough, plain ssh, CI, a dumb +//! terminal) structurally cannot answer and never will, this launch or the next. This module +//! remembers that one verdict so later launches from the same terminal skip the probe and go +//! straight to the curated fallback ([`crate::theme::Palette::dark`] — exactly what an empty +//! probe result already produces, so a cache hit changes timing, never the resulting palette). +//! +//! **Only silence is cached.** A terminal that answers ANYTHING — even just the DA1 sentinel, +//! even a partial/malformed color — returns in milliseconds and is never recorded here, so live +//! theme detection (e.g. macOS Terminal flipping between its light and dark profiles) keeps +//! 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. +//! +//! ## Store +//! A small human-readable JSON array of `{tty, term, term_program, timestamp}` objects under +//! [`dirs::cache_dir`]`/git-workon-review/silent-terminals.json` (overridable via the +//! `WORKON_REVIEW_PROBE_CACHE` env var — used by the PTY test suites to keep them off the real +//! user cache; see `tests/pty_support/mod.rs`). Entries expire after [`TTL_SECS`] (30 days) and +//! are pruned opportunistically the next time an entry is written, so the file never grows +//! unboundedly across many terminals. +//! +//! ## Escaping a wrong verdict +//! A "silent" verdict recorded against a terminal that later becomes capable of answering (rare — +//! e.g. reconfiguring tmux passthrough) self-heals after [`TTL_SECS`]. To force it sooner: delete +//! the cache file, or pin `workon.review.theme` to `dark`/`light` instead of `auto`. +//! +//! ## Failure posture +//! Every step here — resolving the cache dir, reading, parsing, writing — degrades silently to +//! "probe again": a missing/corrupt/unwritable cache file, no resolvable cache dir, or no +//! controlling tty (so no key) all fall through to [`crate::terminal_query::detect_auto_palette`] +//! running its probe exactly as it would with no cache at all. No new error type exists for this +//! module on purpose — there is nothing for a caller to react to. + +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{json, Value}; + +/// How long a "silent terminal" verdict stays trusted before the probe runs again. +const TTL_SECS: u64 = 30 * 24 * 60 * 60; + +/// Identifies one controlling terminal window for cache lookups — see the module doc's "Key" +/// section for the rationale behind each field. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TerminalKey { + tty: String, + term: String, + 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. +#[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 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; + } + // Safety: `ttyname_r` returning 0 guarantees `buf` holds a NUL-terminated string. + let tty_path = unsafe { CStr::from_ptr(buf.as_ptr()) } + .to_str() + .ok()? + .to_string(); + + Some(TerminalKey { + tty: tty_path, + term: std::env::var("TERM").unwrap_or_default(), + term_program: std::env::var("TERM_PROGRAM").unwrap_or_default(), + }) +} + +#[cfg(not(unix))] +pub(crate) fn terminal_key() -> Option { + None +} + +/// Whether `key` has a live (unexpired) "silent" verdict cached. Any failure to resolve or read +/// the cache — no cache dir, missing/corrupt file — reports `false`, so the caller probes. +pub(crate) fn is_cached_silent(key: &TerminalKey) -> bool { + match cache_path() { + Some(path) => is_silent_at(&path, key, now_unix()), + None => false, + } +} + +/// Record that `key`'s terminal timed out silent on this launch (which already paid the full +/// probe deadline). Best-effort: any failure to resolve the cache dir or write the file is +/// swallowed — a launch that can't cache its verdict just probes again next time, same as today. +pub(crate) fn record_silent(key: &TerminalKey) { + if let Some(path) = cache_path() { + record_silent_at(&path, key, now_unix()); + } +} + +/// The cache file's location: `WORKON_REVIEW_PROBE_CACHE` when set (the PTY test suites' escape +/// hatch — see the module doc), else `dirs::cache_dir()/git-workon-review/silent-terminals.json`. +/// `None` when neither resolves (no `HOME`/`XDG_CACHE_HOME` equivalent for `dirs` to find). +fn cache_path() -> Option { + if let Ok(overridden) = std::env::var("WORKON_REVIEW_PROBE_CACHE") { + return Some(PathBuf::from(overridden)); + } + Some( + dirs::cache_dir()? + .join("git-workon-review") + .join("silent-terminals.json"), + ) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +// ── The pure, path-injected core (unit-tested against a temp dir, never the real cache) ──────── + +/// `true` when `path`'s cache holds a `key` entry whose `timestamp` is within [`TTL_SECS`] of +/// `now`. A missing or corrupt file, or an all-expired/no-match set of entries, is `false`. +fn is_silent_at(path: &Path, key: &TerminalKey, now: u64) -> bool { + read_entries(path) + .iter() + .any(|entry| entry_matches(entry, key) && entry_is_live(entry, now)) +} + +/// Write a fresh `key` verdict timestamped `now`, first dropping any expired entry (per +/// [`TTL_SECS`]) and any existing entry for `key` (a re-verdict replaces, not duplicates). Silent +/// on any I/O failure — see the module doc's "Failure posture". +fn record_silent_at(path: &Path, key: &TerminalKey, now: u64) { + let mut entries = read_entries(path); + entries.retain(|entry| entry_is_live(entry, now) && !entry_matches(entry, key)); + entries.push(json!({ + "tty": key.tty, + "term": key.term, + "term_program": key.term_program, + "timestamp": now, + })); + write_entries(path, &entries); +} + +fn entry_matches(entry: &Value, key: &TerminalKey) -> bool { + entry.get("tty").and_then(Value::as_str) == Some(key.tty.as_str()) + && entry.get("term").and_then(Value::as_str) == Some(key.term.as_str()) + && entry.get("term_program").and_then(Value::as_str) == Some(key.term_program.as_str()) +} + +fn entry_is_live(entry: &Value, now: u64) -> bool { + matches!( + entry.get("timestamp").and_then(Value::as_u64), + Some(ts) if now.saturating_sub(ts) < TTL_SECS + ) +} + +/// Read the cache file into a list of raw JSON entries. Anything short of "a valid JSON array" — +/// a missing file, unreadable file, malformed JSON, or JSON that isn't an array — is treated as +/// an empty cache, never an error. +fn read_entries(path: &Path) -> Vec { + let Ok(contents) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + match serde_json::from_str::(&contents) { + Ok(Value::Array(entries)) => entries, + _ => Vec::new(), + } +} + +/// Best-effort pretty-printed write of `entries`, creating the parent directory if needed. Any +/// failure (read-only filesystem, missing permissions, ...) is swallowed. +fn write_entries(path: &Path, entries: &[Value]) { + if let Some(parent) = path.parent() { + if std::fs::create_dir_all(parent).is_err() { + return; + } + } + if let Ok(text) = serde_json::to_string_pretty(&Value::Array(entries.to_vec())) { + let _ = std::fs::write(path, text); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(tty: &str) -> TerminalKey { + TerminalKey { + tty: tty.to_string(), + term: "xterm-256color".to_string(), + term_program: "iTerm.app".to_string(), + } + } + + fn cache_file() -> (assert_fs::TempDir, PathBuf) { + let dir = assert_fs::TempDir::new().expect("temp dir"); + let path = dir.path().join("silent-terminals.json"); + (dir, path) + } + + #[test] + fn missing_file_is_not_silent() { + let (_dir, path) = cache_file(); + assert!(!is_silent_at(&path, &key("/dev/ttys000"), 1_000)); + } + + #[test] + fn record_then_lookup_round_trips() { + let (_dir, path) = cache_file(); + let k = key("/dev/ttys000"); + record_silent_at(&path, &k, 1_000); + assert!( + is_silent_at(&path, &k, 1_500), + "just-recorded verdict must be live" + ); + } + + #[test] + fn a_different_tty_term_or_term_program_is_a_miss() { + let (_dir, path) = cache_file(); + record_silent_at(&path, &key("/dev/ttys000"), 1_000); + + assert!( + !is_silent_at(&path, &key("/dev/ttys001"), 1_000), + "different tty" + ); + + let mut different_term = key("/dev/ttys000"); + different_term.term = "screen".to_string(); + assert!( + !is_silent_at(&path, &different_term, 1_000), + "different $TERM" + ); + + let mut different_program = key("/dev/ttys000"); + different_program.term_program = "Apple_Terminal".to_string(); + assert!( + !is_silent_at(&path, &different_program, 1_000), + "different $TERM_PROGRAM — guards against tty-number recycling" + ); + } + + #[test] + fn an_expired_entry_is_ignored() { + let (_dir, path) = cache_file(); + let k = key("/dev/ttys000"); + record_silent_at(&path, &k, 1_000); + + assert!( + is_silent_at(&path, &k, 1_000 + TTL_SECS - 1), + "just inside the TTL" + ); + assert!( + !is_silent_at(&path, &k, 1_000 + TTL_SECS), + "exactly at the TTL boundary" + ); + assert!( + !is_silent_at(&path, &k, 1_000 + TTL_SECS + 1_000), + "well past the TTL" + ); + } + + #[test] + fn writing_prunes_expired_entries() { + let (_dir, path) = cache_file(); + let stale = key("/dev/ttys000"); + let fresh = key("/dev/ttys001"); + record_silent_at(&path, &stale, 1_000); + // Advance well past the stale entry's TTL, then record a second (different) verdict — + // the write must drop the stale entry rather than accumulate it forever. + record_silent_at(&path, &fresh, 1_000 + TTL_SECS + 1); + + let entries = read_entries(&path); + assert_eq!( + entries.len(), + 1, + "the expired entry must be pruned on write" + ); + assert!(entry_matches(&entries[0], &fresh)); + } + + #[test] + fn re_recording_the_same_key_replaces_rather_than_duplicates() { + let (_dir, path) = cache_file(); + let k = key("/dev/ttys000"); + record_silent_at(&path, &k, 1_000); + record_silent_at(&path, &k, 2_000); + + let entries = read_entries(&path); + assert_eq!(entries.len(), 1, "a re-verdict must replace, not duplicate"); + assert_eq!( + entries[0].get("timestamp").and_then(Value::as_u64), + Some(2_000) + ); + } + + #[test] + fn a_corrupt_file_is_tolerated_and_overwritten() { + let (_dir, path) = cache_file(); + std::fs::write(&path, b"not json at all { [").expect("write garbage"); + + assert!( + !is_silent_at(&path, &key("/dev/ttys000"), 1_000), + "corrupt file must read back as no cache, not a crash" + ); + + // Recovery: a subsequent write must succeed and be readable, proving the corrupt file + // doesn't wedge the cache permanently. + record_silent_at(&path, &key("/dev/ttys000"), 1_000); + assert!(is_silent_at(&path, &key("/dev/ttys000"), 1_000)); + } + + #[test] + fn a_missing_cache_directory_is_created_on_write() { + let dir = assert_fs::TempDir::new().expect("temp dir"); + let path = dir.path().join("nested").join("silent-terminals.json"); + record_silent_at(&path, &key("/dev/ttys000"), 1_000); + assert!(path.exists(), "write must create missing parent dirs"); + } +} diff --git a/git-workon-review/src/terminal_query.rs b/git-workon-review/src/terminal_query.rs index f854473..174dbff 100644 --- a/git-workon-review/src/terminal_query.rs +++ b/git-workon-review/src/terminal_query.rs @@ -41,6 +41,7 @@ use std::time::Duration; use ratatui::style::Color; +use crate::probe_cache; use crate::theme::{self, tint_toward, Base16, Palette}; /// The colors read back from a terminal OSC probe. `ansi16` is `Some` only if **all 16** ANSI @@ -65,8 +66,31 @@ pub struct ProbeResult { /// full deadline — and giving up early on a merely-slow terminal is worse than the wait, because /// replies that arrive after the probe stopped listening leak into crossterm as phantom /// keystrokes (`r` → refresh storms, `d` → a discard confirm that captures the keyboard). -pub fn detect_auto_palette() -> Palette { - palette_for_auto(&probe_terminal(Duration::from_millis(800))) +/// +/// [`crate::probe_cache`] remembers a terminal that has already paid this deadline and gotten +/// nothing back: when this launch's controlling terminal has a live "silent" verdict cached, the +/// probe is skipped entirely and the curated fallback returns immediately (identical to what an +/// empty probe result would have produced). A terminal that answers ANYTHING is never cached, so +/// live detection keeps working there every launch. +/// +/// The second element of the returned tuple is whether a real probe conversation happened on the +/// controlling tty this call — `false` only on a cache hit. `main.rs` uses it (instead of just +/// "theme was auto") to decide whether [`flush_pending_tty_input`] is needed: a cache hit writes +/// nothing to the tty, so no replies are ever owed and flushing would only risk eating legitimate +/// type-ahead (see that function's doc comment). +pub fn detect_auto_palette() -> (Palette, bool) { + let key = probe_cache::terminal_key(); + if key.as_ref().is_some_and(probe_cache::is_cached_silent) { + return (Palette::dark(), false); + } + + let (probe, timed_out_silent) = probe_terminal(Duration::from_millis(800)); + if timed_out_silent { + if let Some(key) = &key { + probe_cache::record_silent(key); + } + } + (palette_for_auto(&probe), true) } /// Discard any bytes pending on the controlling tty's input queue. `main.rs` calls this after the @@ -148,21 +172,43 @@ pub fn build_base16(ansi: &[Color; 16], background: Color, foreground: Option ProbeResult { +/// +/// The second element is `true` only when [`query_terminal_raw`] paid the FULL `timeout` and +/// still got zero reply bytes — [`probe_cache`]'s one cacheable case. A terminal that answered +/// (even partially) or a probe that couldn't even start (no `/dev/tty`, not a tty, a failed +/// write) are both `false`: the former has nothing to cache, the latter never waited long enough +/// for caching to save anything. +fn probe_terminal(timeout: Duration) -> (ProbeResult, bool) { #[cfg(unix)] { match query_terminal_raw(&build_query(), timeout) { - Some(bytes) => parse_osc_replies(&bytes), - None => ProbeResult::default(), + ProbeOutcome::Replied(bytes) => (parse_osc_replies(&bytes), false), + ProbeOutcome::TimedOutSilent => (ProbeResult::default(), true), + ProbeOutcome::Unavailable => (ProbeResult::default(), false), } } #[cfg(not(unix))] { let _ = timeout; - ProbeResult::default() + (ProbeResult::default(), false) } } +/// The outcome of one attempt at [`query_terminal_raw`] — distinguishes "the terminal answered" +/// from the two different ways it can answer nothing, only one of which is worth caching (see +/// [`probe_terminal`]'s doc comment). +#[cfg(unix)] +enum ProbeOutcome { + /// At least one reply byte arrived. + Replied(Vec), + /// The probe wrote its query, waited the full `timeout`, and got nothing back — the terminal + /// this launch already paid the deadline for. + TimedOutSilent, + /// Probing wasn't possible this launch at all (no `/dev/tty`, not a tty, the query write + /// failed) — always fast, never worth remembering. + Unavailable, +} + /// The bytes we write to the terminal: `OSC 4;n;?` for each of the 16 ANSI colors, then /// `OSC 11;?` (background) and `OSC 10;?` (foreground), then a primary Device Attributes query /// (`ESC [ c`). Terminals answer in order, so the DA1 reply is a sentinel: once we see it, every @@ -312,24 +358,31 @@ fn has_da1_terminator(bytes: &[u8]) -> bool { /// is the one function the unit tests do NOT call (it needs a real tty); everything it feeds /// ([`parse_osc_replies`], [`build_base16`], [`palette_for_auto`]) is pure and tested directly. /// -/// Returns the raw reply bytes, or `None` if `/dev/tty` can't be opened, isn't a tty, or the read -/// yields nothing before the timeout. `None` and an empty read both degrade to the curated -/// fallback upstream. +/// Returns a [`ProbeOutcome`]: `Replied` bytes, `TimedOutSilent` when the full `timeout` elapsed +/// with nothing back, or `Unavailable` when `/dev/tty` can't be opened, isn't a tty, or the query +/// write itself fails (all of which return fast, well under `timeout`). The +/// `elapsed >= timeout` check distinguishing the latter two is deliberately a wall-clock +/// comparison rather than a distinct signal threaded up from [`read_replies`] — it needs no +/// change to that function or its already-covered unit tests, and the two cases are only ever +/// milliseconds vs. the full deadline apart. #[cfg(unix)] -fn query_terminal_raw(query: &[u8], timeout: Duration) -> Option> { +fn query_terminal_raw(query: &[u8], timeout: Duration) -> ProbeOutcome { use std::os::unix::io::AsRawFd; + use std::time::Instant; - let mut tty = std::fs::File::options() + let Ok(mut tty) = std::fs::File::options() .read(true) .write(true) .open("/dev/tty") - .ok()?; + else { + return ProbeOutcome::Unavailable; + }; let fd = tty.as_raw_fd(); // Save the current termios; bail (leaving the tty untouched) if this isn't a tty. let mut saved: libc::termios = unsafe { std::mem::zeroed() }; if unsafe { libc::tcgetattr(fd, &mut saved) } != 0 { - return None; + return ProbeOutcome::Unavailable; } // Switch to raw so the OSC replies (terminated by ST/BEL, not newline) arrive uncooked and @@ -341,9 +394,10 @@ fn query_terminal_raw(query: &[u8], timeout: Duration) -> Option> { raw.c_cc[libc::VMIN] = 0; raw.c_cc[libc::VTIME] = 1; if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 { - return None; // termios unchanged — nothing to restore + return ProbeOutcome::Unavailable; // termios unchanged — nothing to restore } + let started = Instant::now(); let outcome = read_replies(&mut tty, fd, query, timeout); // Discard anything still in the terminal's input queue before handing the tty back — a @@ -356,7 +410,12 @@ fn query_terminal_raw(query: &[u8], timeout: Duration) -> Option> { // ALWAYS restore, on success or failure. unsafe { libc::tcsetattr(fd, libc::TCSANOW, &saved) }; - outcome + + match outcome { + Some(bytes) => ProbeOutcome::Replied(bytes), + None if started.elapsed() >= timeout => ProbeOutcome::TimedOutSilent, + None => ProbeOutcome::Unavailable, // the query write failed — an early bail, not a wait + } } /// The read half of [`query_terminal_raw`], factored out so `termios` restoration wraps it on diff --git a/git-workon-review/tests/pty_smoke.rs b/git-workon-review/tests/pty_smoke.rs index 5b72c5f..0d9902f 100644 --- a/git-workon-review/tests/pty_smoke.rs +++ b/git-workon-review/tests/pty_smoke.rs @@ -68,12 +68,19 @@ fn answer_probe(session: &mut Session) { session.flush().expect("flush probe replies"); } -/// Wait for the TUI to be up (alternate screen entered), let any straggler reply bytes land, -/// then press `q` and require a prompt exit. -fn assert_q_quits_promptly(mut session: Session) { +/// Wait for the TUI to be up (alternate screen entered). Split out from +/// [`assert_q_quits_promptly`] so a caller that needs to time spawn→alternate-screen itself can +/// do so without that function re-`expect`-ing a step already consumed. +fn wait_for_alt_screen(session: &mut Session) { session .expect("\x1b[?1049h") // EnterAlternateScreen — tui::run has the terminal .expect("TUI entered the alternate screen"); +} + +/// Wait for the TUI to be up, let any straggler reply bytes land, then press `q` and require a +/// prompt exit. +fn assert_q_quits_promptly(mut session: Session) { + wait_for_alt_screen(&mut session); // Give leaked bytes (the regression case) time to reach crossterm before q, so a regressed // binary deterministically has its discard-confirm modal up — and swallows the q. @@ -109,9 +116,26 @@ fn theme_auto_stays_responsive_when_the_terminal_answers() { #[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty_smoke -- --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. + // cost at most the probe deadline, then fall back to a curated theme and run normally. This + // launch also exercises the `probe_cache` write path (a timed-out-silent probe records a + // verdict) — see `spawn_review`'s doc comment for how the cache file is kept off the real + // user cache during this run. let fixture = auto_theme_fixture(); let session = spawn_review(&fixture); assert_q_quits_promptly(session); + + // 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 + // the same controlling tty skips the probe and reaches the alternate screen in well under a + // millisecond) and is unit-tested at the cache layer in `probe_cache.rs`. It does NOT fit + // cleanly as a second `spawn_review` in THIS test, though: back-to-back `expectrl` sessions + // in one test process reproducibly hit `ExpectTimeout` waiting for the alternate-screen + // sequence on the second (cache-hit-fast) launch specifically, even though `Session::check` + // proves the bytes are actually present in the stream at that point — an `expectrl`/PTY + // interaction this suite's existing patterns (a single `spawn_review` per test) don't hit. + // Chasing that harness quirk was out of scope here; two-process verification stays a manual + // workflow for this one behavior, same posture pty_responsiveness.rs takes for precise + // per-phase timings. } diff --git a/git-workon-review/tests/pty_support/mod.rs b/git-workon-review/tests/pty_support/mod.rs index 1253c86..1887a73 100644 --- a/git-workon-review/tests/pty_support/mod.rs +++ b/git-workon-review/tests/pty_support/mod.rs @@ -15,12 +15,25 @@ use git_workon_fixture::prelude::*; /// Spawn the review binary in a PTY sized like a real terminal (an unsized PTY is 0×0 and /// ratatui draws nothing), cwd'd into the fixture's worktree. +/// +/// `WORKON_REVIEW_PROBE_CACHE` is pinned to a file inside the fixture's own tempdir (never the +/// real user cache dir): without this, the `theme = auto` silent-terminal cache (added alongside +/// this comment) would read and write the developer's/CI runner's actual +/// `dirs::cache_dir()/git-workon-review/silent-terminals.json` — a second run of the silent-PTY +/// test on the same real terminal would then hit a live cache entry from a PRIOR run and skip +/// the probe, breaking `theme_auto_stays_responsive_when_the_terminal_is_silent`'s timing +/// assumptions (and leaking test state into the real cache to boot). Deriving the path from the +/// 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 mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_git-workon-review")); - cmd.current_dir(workdir).env("TERM", "xterm-256color"); + cmd.current_dir(&workdir) + .env("TERM", "xterm-256color") + .env("WORKON_REVIEW_PROBE_CACHE", &probe_cache); let mut session = expectrl::Session::spawn(cmd).expect("spawn in PTY"); session From 7161f5eabcd0d7b8ceab00393f098991bb9891dc Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 17:35:58 -0400 Subject: [PATCH 099/203] feat(review): rework outline focus into home-base model with h/l --- git-workon-review/src/app.rs | 127 +++++++++--- git-workon-review/src/keymap.rs | 26 ++- git-workon-review/src/tui.rs | 338 ++++++++++++++++++++++++++------ 3 files changed, 402 insertions(+), 89 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index acf167c..02141a0 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1008,6 +1008,9 @@ impl App { // "decided without interview" default — preserves the M4 full-width look for a lone // uncommitted changeset), unfocused (the diff keeps initial keyboard focus so the user // can start reading immediately), Stack mode (shows the structure M5 exists to surface). + // Under the pure open/closed toggle (`o`) this is now a consistent split: `o` controls + // visibility, `h`/[`App::focus_outline`] controls focus — so seeding open+unfocused here + // doesn't fight the toggle the way it did under the old three-state cycle. let outline = OutlineState { open: changesets.len() > 1, focused: false, @@ -2123,37 +2126,46 @@ impl App { self.outline.mode } - /// `o`: a three-state cycle — closed -> open+focused -> open+unfocused (focus back on the - /// diff, pane stays visible) -> closed. Opening always grabs focus (per the locked design); - /// the middle -> closed transition ("o while the outline is open but the diff has focus - /// closes it") isn't explicitly specified in the plan but is the natural completion of the - /// cycle, kept simple rather than adding a separate "close" key. + /// `o`: a pure show/hide toggle — closed -> open+focused (+[`Self::sync_outline_to_current`]), + /// open (regardless of focus) -> closed+diff-focused. Focus itself is now a separate concern + /// handled by [`Self::focus_outline`]/[`Self::focus_diff`] (`h`/`l`) — `o` only ever changes + /// visibility. pub fn toggle_outline(&mut self) { if !self.outline.open { self.outline.open = true; self.outline.focused = true; self.sync_outline_to_current(); - } else if self.outline.focused { - self.outline.focused = false; } else { self.outline.open = false; + self.outline.focused = false; + } + } + + /// `h`/Esc-cascade target: focus the outline, opening it first if it's closed. Syncing the + /// cursor to the current diff position only happens on the closed -> open transition — if the + /// outline is already open, re-focusing it (e.g. `h` after a manual `j`/`k` outline move + /// followed by `l`) must not stomp a manually positioned cursor. + pub fn focus_outline(&mut self) { + if !self.outline.open { + self.outline.open = true; + self.sync_outline_to_current(); } + self.outline.focused = true; + } + + /// `l`/Enter: return focus to the diff. The outline stays open — this only ever changes + /// focus, never visibility (that's `o`/[`Self::toggle_outline`]'s job). + pub fn focus_diff(&mut self) { + self.outline.focused = false; } /// `?`: toggle the help overlay (CS3). A plain flip — the overlay always renders whatever /// view currently has keyboard focus (see `render::render_help_overlay`), so there is no - /// extra state to reposition here, unlike [`Self::toggle_outline`]'s three-state cycle. + /// extra state to reposition here, unlike [`Self::toggle_outline`]. pub fn toggle_help(&mut self) { self.help_visible = !self.help_visible; } - /// Return focus to the diff without closing the outline (`Esc` while the outline has focus — - /// `tui::update` routes it here instead of quitting, per the locked design's "Esc must still - /// not quit when the outline has focus"). - pub fn outline_unfocus(&mut self) { - self.outline.focused = false; - } - /// `i` while the outline has focus: cycle [`OutlineMode`], then reposition the cursor onto /// the row matching the current diff position in the NEW mode's row list (the row layout /// just changed shape, so the raw index would otherwise point at an unrelated row). @@ -6726,7 +6738,7 @@ mod tests { } #[test] - fn toggle_outline_cycles_closed_open_focused_open_unfocused_closed() { + fn toggle_outline_is_a_pure_show_hide_toggle() { let mut app = two_committed_changesets_two_and_one_files(); // Force a known starting state regardless of the default. while app.outline_open() { @@ -6737,19 +6749,88 @@ mod tests { app.toggle_outline(); assert!( app.outline_open() && app.outline_focused(), - "opening focuses" + "o from closed opens AND focuses" ); app.toggle_outline(); assert!( - app.outline_open() && !app.outline_focused(), - "toggling while focused returns focus to the diff without closing" + !app.outline_open() && !app.outline_focused(), + "o from open+focused closes — the toggle only ever tracks visibility" ); + // Re-open, then unfocus without going through `toggle_outline` (mirrors the startup + // seed: open, but diff-focused) — `o` from THAT state must still close, not cycle + // through a middle focused-then-unfocused state. + app.toggle_outline(); + app.focus_diff(); + assert!(app.outline_open() && !app.outline_focused()); + app.toggle_outline(); assert!( - !app.outline_open(), - "toggling again while open-but-unfocused closes the pane" + !app.outline_open() && !app.outline_focused(), + "o from open+unfocused closes the pane" + ); + } + + #[test] + fn focus_outline_opens_when_closed_and_syncs_the_cursor() { + let mut app = two_committed_changesets_two_and_one_files(); + while app.outline_open() { + app.toggle_outline(); + } + assert!(!app.outline_open()); + // Move the diff onto the second changeset before focusing, so a sync is observable. + app.next_changeset(); + let current_cs = app.current_cs(); + + app.focus_outline(); + + assert!(app.outline_open() && app.outline_focused()); + let items = app.outline_items(); + assert!( + matches!( + items[app.outline_cursor()], + crate::outline::OutlineItem::File { cs_idx, .. } if cs_idx == current_cs + ), + "opening via focus_outline syncs the cursor to the current diff position" + ); + } + + #[test] + fn focus_outline_on_an_already_open_outline_does_not_move_the_cursor() { + let mut app = two_committed_changesets_two_and_one_files(); + while app.outline_open() { + app.toggle_outline(); + } + app.toggle_outline(); // open + focus, synced + app.outline_move_by(-1); // manually reposition the outline cursor + app.focus_diff(); + let cursor_before = app.outline_cursor(); + + app.focus_outline(); + + assert!(app.outline_focused()); + assert_eq!( + app.outline_cursor(), + cursor_before, + "re-focusing an already-open outline must not stomp a manually positioned cursor" + ); + } + + #[test] + fn focus_diff_unfocuses_without_closing_the_outline() { + let mut app = two_committed_changesets_two_and_one_files(); + while app.outline_open() { + app.toggle_outline(); + } + app.toggle_outline(); // open + focus + assert!(app.outline_open() && app.outline_focused()); + + app.focus_diff(); + + assert!( + app.outline_open() && !app.outline_focused(), + "focus_diff unfocuses but leaves the outline open" ); } @@ -7409,8 +7490,8 @@ mod tests { // contract `render::render` reads (`outline_open`), so a regression there is caught at // the state layer too. let mut app = two_committed_changesets_two_and_one_files(); - // Default state is open+unfocused (locked design), so a single `o` here hits the - // "open, diff has focus" branch of the cycle, which closes the pane. + // Default state is open+unfocused (locked design); the pure toggle closes it regardless + // of focus. assert!(app.outline_open() && !app.outline_focused()); app.toggle_outline(); assert!(!app.outline_open()); diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 2414b32..7cc9bc4 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -17,9 +17,10 @@ //! action names and same-view key collisions 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 > outline-unfocus > selection-cancel > quit). Per -//! ADR-028 those are conventional and safety-sensitive; they are never routed through the -//! registry, so `Esc` is not a registry token. +//! whole `Esc`-precedence cascade (confirm > help > selection-cancel > outline-focused-quit > +//! focus-outline > quit — see `tui::update`'s doc comment). Per ADR-028 those are conventional +//! and safety-sensitive; they are never routed through the registry, so `Esc` is not a registry +//! token. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -61,11 +62,14 @@ pub enum Command { PrevHunk, NextChangeset, PrevChangeset, + // Diff view. + FocusOutline, // Outline view. OutlineDown, OutlineUp, OutlineConfirm, OutlineCycleMode, + FocusDiff, } /// One row of the action registry: a [`Command`] with its stable config identity (`view` + @@ -101,7 +105,7 @@ pub static REGISTRY: &[Registered] = &[ view: View::Global, name: "toggle-outline", default_keys: "o", - description: "Toggle the outline pane / focus", + description: "Show or hide the outline pane", }, Registered { command: Command::ToggleHelp, @@ -258,6 +262,13 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "[c", description: "Go to the previous changeset", }, + Registered { + command: Command::FocusOutline, + view: View::Diff, + name: "focus-outline", + default_keys: "h left", + description: "Focus the outline", + }, // ── Outline view ───────────────────────────────────────────────────────── Registered { command: Command::OutlineDown, @@ -287,6 +298,13 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "i", description: "Cycle the outline mode", }, + Registered { + command: Command::FocusDiff, + view: View::Outline, + name: "focus-diff", + default_keys: "l right", + description: "Focus the diff view", + }, ]; /// One matchable key press: a [`KeyCode`] plus whether Ctrl/Alt are required. **Shift is diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 0b61efd..c214dc4 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -417,7 +417,8 @@ enum Action { OutlineMoveBy(i64), OutlineConfirm, OutlineCycleMode, - OutlineUnfocus, + FocusOutline, + FocusDiff, None, } @@ -456,13 +457,15 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::OutlineUp => Action::OutlineMoveBy(-1), Command::OutlineConfirm => Action::OutlineConfirm, Command::OutlineCycleMode => Action::OutlineCycleMode, + Command::FocusOutline => Action::FocusOutline, + Command::FocusDiff => Action::FocusDiff, } } /// Map one key press to an [`Action`] through the resolved [`Keymap`], given `pending` (the /// in-flight multi-key sequence buffer — generalized from the old `]`/`[` bracket chord to ANY /// bound sequence), the current pane height (for the half-page deltas), and whether the outline -/// pane currently has focus. +/// pane currently has focus/is open. /// /// Dispatch order: /// 1. The keymap ([`Keymap::advance`]) consumes the key. A bound sequence fires its command; a @@ -470,8 +473,9 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { /// unrecognized suffix mid-sequence drops the buffer without re-processing (the old /// bracket-drop behavior, now general). /// 2. `Esc` stays HARDCODED (ADR-028: the whole `Esc`-precedence cascade is never routed through -/// the registry). Reached only as a fresh, otherwise-unbound key: it unfocuses the outline when -/// the outline has focus, else quits — the terminal leaf of the cascade `update` enforces. +/// the registry). Reached only as a fresh, otherwise-unbound key, it walks outward: the outline +/// having focus quits (same terminal leaf as `q`); otherwise, with the outline open, it focuses +/// the outline (home-base model: `h`/`FocusOutline`'s effect); otherwise it quits. /// /// `outline_focused` selects the keymap's outline vs diff context; the global bindings (`q`/`o`) /// are active in both, so `o` toggles and `q` quits from either pane. @@ -481,6 +485,7 @@ fn map_key( key: KeyEvent, pane_height: usize, outline_focused: bool, + outline_open: bool, ) -> Action { match keymap.advance(outline_focused, pending, key) { Dispatch::Command(command) => command_to_action(command, pane_height), @@ -488,7 +493,9 @@ fn map_key( Dispatch::Unmatched { mid_sequence } => { if !mid_sequence && key.code == KeyCode::Esc { if outline_focused { - Action::OutlineUnfocus + Action::Quit + } else if outline_open { + Action::FocusOutline } else { Action::Quit } @@ -563,7 +570,8 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::OutlineMoveBy(delta) => app.outline_move_by(delta), Action::OutlineConfirm => app.outline_confirm(), Action::OutlineCycleMode => app.outline_cycle_mode(), - Action::OutlineUnfocus => app.outline_unfocus(), + Action::FocusOutline => app.focus_outline(), + Action::FocusDiff => app.focus_diff(), Action::None => {} } false @@ -603,6 +611,7 @@ fn resolve_key( key, app.pane_height, app.outline_focused(), + app.outline_open(), )) } @@ -615,9 +624,10 @@ fn resolve_key( /// message and performs its normal action. `Resize`/`Tick` do NOT clear it: a redraw or timer /// tick isn't the user acting on the message. /// -/// Esc precedence (highest first): a pending discard confirm > the help overlay being open > the -/// outline having focus > an active line selection > the normal key map (where Esc quits). -/// Concretely: +/// Esc precedence (highest first): a pending discard confirm > the help overlay being open > an +/// active line selection (diff-focused) > the outline having focus > the diff having focus with +/// the outline open > the normal key map (where Esc quits). Concretely — the home-base model: +/// the outline is where Esc always eventually lands you before it quits. /// /// 1. A pending discard confirm captures the keyboard FIRST (before the notice clear and the /// normal key map): `y` accepts, `n`/`Esc` cancels, and every other key is swallowed — a modal @@ -627,17 +637,20 @@ fn resolve_key( /// reacts). Ranked just below the confirm modal — in practice the two are never up /// together, since opening help doesn't run through a confirm, but the confirm winning keeps /// a destructive prompt from ever being silently dismissed by a stray overlay key. -/// 3. Otherwise, while the outline pane has focus, Esc returns focus to the diff (via the normal -/// map's `outline_focused` branch — see [`map_key`]) rather than quitting or falling into the -/// selection-cancel case below (locked design: "Esc must still not quit when the outline has -/// focus"). The selection-Esc arm below is guarded to defer to this case. -/// 4. Otherwise, with an active line selection, Esc CANCELS the selection instead of quitting (`q` -/// still quits). Other keys fall through to the normal map — `j`/`k` extend the selection, -/// `s`/`d` act on it. -/// 5. Otherwise the normal map applies, where Esc (like `q`) quits. +/// 3. Otherwise, with an active line selection AND the diff focused, Esc CANCELS the selection +/// instead of moving focus or quitting (`q` still quits). This arm is guarded to defer to case +/// 4 when the outline has focus (a selection can only be active while looking at the diff, but +/// the guard keeps the precedence explicit). Other keys fall through to the normal map — +/// `j`/`k` extend the selection, `s`/`d` act on it. +/// 4. Otherwise, while the outline pane has focus, Esc QUITS — same terminal leaf as `q`. The +/// outline is home base; there's nowhere further out to walk to. +/// 5. Otherwise, with the diff focused and the outline OPEN, Esc walks outward one step: it +/// focuses the outline (same effect as `h`/[`App::focus_outline`]) rather than quitting. +/// 6. Otherwise (diff focused, outline closed) the normal map applies, where Esc (like `q`) quits +/// — there's no outline to walk out to. /// -/// A `Key` event clears any showing footer notice before applying its own action (cases 3-5); the -/// confirm and help modals (cases 1-2) deliberately do not. Cases 3-5 are delegated to +/// A `Key` event clears any showing footer notice before applying its own action (cases 3-6); the +/// confirm and help modals (cases 1-2) deliberately do not. Cases 3-6 are delegated to /// [`resolve_key`], shared with [`update_batch`]. fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: AppEvent) -> bool { match event { @@ -1224,11 +1237,11 @@ mod tests { let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('q')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('q')), 20, false, false), Action::Quit ); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Esc), 20, false), + map_key(&km, &mut pending, key(KeyCode::Esc), 20, false, false), Action::Quit ); } @@ -1238,19 +1251,19 @@ mod tests { let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('j')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('j')), 20, false, false), Action::MoveCursorBy(1) ); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Down), 20, false), + map_key(&km, &mut pending, key(KeyCode::Down), 20, false, false), Action::MoveCursorBy(1) ); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('k')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('k')), 20, false, false), Action::MoveCursorBy(-1) ); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Up), 20, false), + map_key(&km, &mut pending, key(KeyCode::Up), 20, false, false), Action::MoveCursorBy(-1) ); } @@ -1260,16 +1273,16 @@ mod tests { let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&km, &mut pending, ctrl_key('d'), 21, false), + map_key(&km, &mut pending, ctrl_key('d'), 21, false, false), Action::MoveCursorBy(10) ); assert_eq!( - map_key(&km, &mut pending, ctrl_key('u'), 21, false), + map_key(&km, &mut pending, ctrl_key('u'), 21, false, false), Action::MoveCursorBy(-10) ); // A pane height of 1 still scrolls by at least one line. assert_eq!( - map_key(&km, &mut pending, ctrl_key('d'), 1, false), + map_key(&km, &mut pending, ctrl_key('d'), 1, false, false), Action::MoveCursorBy(1) ); } @@ -1279,11 +1292,11 @@ mod tests { let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('g')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('g')), 20, false, false), Action::ScrollTop ); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('G')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('G')), 20, false, false), Action::ScrollBottom ); } @@ -1293,7 +1306,7 @@ mod tests { let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('L')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('L')), 20, false, false), Action::ToggleLayout ); } @@ -1303,11 +1316,11 @@ mod tests { let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('z')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('z')), 20, false, false), Action::CycleZoom ); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('w')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('w')), 20, false, false), Action::ToggleSplitFocus ); } @@ -1317,7 +1330,7 @@ mod tests { let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('r')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('r')), 20, false, false), Action::Refresh ); } @@ -1327,11 +1340,11 @@ mod tests { let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Tab), 20, false), + map_key(&km, &mut pending, key(KeyCode::Tab), 20, false, false), Action::NextFile ); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::BackTab), 20, false), + map_key(&km, &mut pending, key(KeyCode::BackTab), 20, false, false), Action::PrevFile ); } @@ -1341,23 +1354,23 @@ mod tests { let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char(']')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char(']')), 20, false, false), Action::None ); // The buffer holds the in-flight chord prefix (generalized from the old `Option`). assert_eq!(pending, vec![KeyPress::from_event(key(KeyCode::Char(']')))]); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('f')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('f')), 20, false, false), Action::NextFile ); assert!(pending.is_empty()); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('[')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('[')), 20, false, false), Action::None ); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('f')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('f')), 20, false, false), Action::PrevFile ); } @@ -1366,15 +1379,15 @@ mod tests { fn bracket_h_maps_to_hunk_nav() { let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); - map_key(&km, &mut pending, key(KeyCode::Char(']')), 20, false); + map_key(&km, &mut pending, key(KeyCode::Char(']')), 20, false, false); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('h')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('h')), 20, false, false), Action::NextHunk ); - map_key(&km, &mut pending, key(KeyCode::Char('[')), 20, false); + map_key(&km, &mut pending, key(KeyCode::Char('[')), 20, false, false); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('h')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('h')), 20, false, false), Action::PrevHunk ); } @@ -1383,9 +1396,9 @@ mod tests { fn unrecognized_bracket_suffix_drops_pending_without_side_effect() { let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); - map_key(&km, &mut pending, key(KeyCode::Char(']')), 20, false); + map_key(&km, &mut pending, key(KeyCode::Char(']')), 20, false, false); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('x')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('x')), 20, false, false), Action::None ); assert!( @@ -1603,24 +1616,24 @@ mod tests { let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('s')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('s')), 20, false, false), Action::StageHunk ); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('S')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('S')), 20, false, false), Action::StageFile ); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('d')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('d')), 20, false, false), Action::DiscardHunk ); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('D')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('D')), 20, false, false), Action::DiscardFile ); // Ctrl-d keeps its half-page meaning — the plain-`d` staging arm must not shadow it. assert_eq!( - map_key(&km, &mut pending, ctrl_key('d'), 20, false), + map_key(&km, &mut pending, ctrl_key('d'), 20, false, false), Action::MoveCursorBy(10) ); } @@ -1630,7 +1643,7 @@ mod tests { let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&km, &mut pending, key(KeyCode::Char('v')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('v')), 20, false, false), Action::StartSelection ); } @@ -1692,6 +1705,48 @@ mod tests { ); } + #[test] + fn esc_cancels_a_selection_before_focusing_the_outline() { + // Even with the outline open (so a bare Esc would otherwise walk out to it), an active + // selection still wins — the outline-focus move is a lower-precedence fallback, not an + // alternative to selection-cancel. + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + // Selection needs a stageable (uncommitted) change; a single changeset seeds the + // outline closed, so open it and hand focus back to the diff. + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.toggle_outline(); + app.focus_diff(); + assert!(app.outline_open() && !app.outline_focused()); + app.start_selection(); + assert!(app.selection_anchor.is_some()); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); + + assert!(!quit, "Esc must not quit while a selection is active"); + assert!( + app.selection_anchor.is_none(), + "Esc cancels the active selection first" + ); + assert!( + !app.outline_focused(), + "the outline-focus move only happens on a LATER Esc, once the selection is gone" + ); + } + #[test] fn pending_confirm_captures_y_and_n_and_ignores_other_keys() { use git_workon_fixture::prelude::*; @@ -1804,7 +1859,7 @@ mod tests { } #[test] - fn o_key_toggles_the_outline_through_its_full_cycle() { + fn o_key_is_a_pure_show_hide_toggle() { use git_workon_fixture::prelude::*; let fixture = FixtureBuilder::new() @@ -1823,7 +1878,10 @@ mod tests { &mut pending, AppEvent::Key(key(KeyCode::Char('o'))), ); - assert!(!app.outline_open(), "o from open+unfocused closes the pane"); + assert!( + !app.outline_open() && !app.outline_focused(), + "o from open+unfocused closes the pane" + ); update( &mut app, @@ -1843,8 +1901,8 @@ mod tests { AppEvent::Key(key(KeyCode::Char('o'))), ); assert!( - app.outline_open() && !app.outline_focused(), - "o from open+focused returns focus to the diff without closing" + !app.outline_open() && !app.outline_focused(), + "o from open+focused closes the pane — the toggle only ever tracks visibility" ); } @@ -1890,7 +1948,83 @@ mod tests { } #[test] - fn esc_does_not_quit_while_the_outline_has_focus() { + fn h_from_the_diff_opens_and_focuses_a_closed_outline() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.toggle_outline(); // close + assert!(!app.outline_open() && !app.outline_focused()); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Char('h'))), + ); + + assert!( + app.outline_open() && app.outline_focused(), + "h from the diff with the outline closed opens AND focuses it" + ); + let items = app.outline_items(); + assert!( + matches!( + items[app.outline_cursor()], + workon_review::outline::OutlineItem::File { cs_idx, file_idx, .. } + if cs_idx == app.current_cs() && file_idx == app.current + ), + "opening via h syncs the outline cursor to the current diff position" + ); + } + + #[test] + fn h_from_the_diff_with_the_outline_already_open_focuses_without_moving_the_cursor() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.toggle_outline(); // close + app.toggle_outline(); // open + focus, synced + app.outline_move_by(-1); // manually reposition + // Return focus to the diff without going through `o` (mirrors `l`), so the outline + // stays open but the diff has keyboard focus. + update( + &mut app, + &Keymap::defaults(), + &mut Vec::new(), + AppEvent::Key(key(KeyCode::Char('l'))), + ); + assert!(app.outline_open() && !app.outline_focused()); + let cursor_before = app.outline_cursor(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Char('h'))), + ); + + assert!(app.outline_focused(), "h focuses the already-open outline"); + assert_eq!( + app.outline_cursor(), + cursor_before, + "h on an already-open outline must not stomp a manually positioned cursor" + ); + } + + #[test] + fn l_from_the_outline_focuses_the_diff_and_leaves_the_outline_open() { use git_workon_fixture::prelude::*; let fixture = FixtureBuilder::new() @@ -1900,6 +2034,35 @@ mod tests { let mut app = two_committed_changesets_app(&fixture); app.toggle_outline(); // close app.toggle_outline(); // open + focus + assert!(app.outline_open() && app.outline_focused()); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Char('l'))), + ); + + assert!( + app.outline_open() && !app.outline_focused(), + "l focuses the diff but leaves the outline open" + ); + } + + #[test] + fn esc_quits_while_the_outline_has_focus() { + // Home-base model: the outline has nowhere further out to walk to, so Esc there is the + // terminal leaf of the cascade — same as `q`. + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.focus_outline(); assert!(app.outline_focused()); let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); @@ -1911,14 +2074,65 @@ mod tests { AppEvent::Key(key(KeyCode::Esc)), ); - assert!(!quit, "Esc must not quit while the outline has focus"); + assert!(quit, "Esc while the outline has focus quits, like q"); + } + + #[test] + fn esc_focuses_the_outline_from_the_diff_when_open() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + // Default: open, unfocused (diff has focus). + assert!(app.outline_open() && !app.outline_focused()); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); + assert!( - !app.outline_focused(), - "Esc while the outline has focus returns focus to the diff" + !quit, + "Esc must not quit when it can walk out to the outline instead" + ); + assert!( + app.outline_focused(), + "Esc from the diff with the outline open focuses the outline" ); + assert!(app.outline_open(), "Esc must not close the pane"); + } + + #[test] + fn esc_quits_from_the_diff_when_the_outline_is_closed() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.toggle_outline(); // close + assert!(!app.outline_open() && !app.outline_focused()); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); + assert!( - app.outline_open(), - "Esc must not also close the pane, only unfocus it" + quit, + "Esc from the diff with the outline closed has nowhere to walk to, so it quits" ); } From 05364c0fd9f1c6fea35a3da65485aba2723c6e51 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 22:49:39 -0400 Subject: [PATCH 100/203] fix(review): renumber resolve_key doc to the six-case Esc cascade --- git-workon-review/src/tui.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index c214dc4..30f49ab 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -587,11 +587,11 @@ enum KeyOutcome { } /// Resolve one `Key` event to a [`KeyOutcome`], given the caller has already ruled out the two -/// modal cases (a pending discard confirm, the help overlay) — this is cases 3-5 of `update`'s +/// modal cases (a pending discard confirm, the help overlay) — this is cases 3-6 of `update`'s /// documented Esc-precedence cascade, extracted so [`update`] and [`update_batch`] share the exact /// same resolution instead of duplicating it. /// -/// Clears any showing footer notice as a side effect, exactly like `update`'s cases 3-5 do (the +/// Clears any showing footer notice as a side effect, exactly like `update`'s cases 3-6 do (the /// confirm/help modals deliberately do not — that stays in their own arms, not here). fn resolve_key( app: &mut App, From 164d06d2cf7bd12f114d99fb8f9c709c958ce7f7 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 22:51:21 -0400 Subject: [PATCH 101/203] fix(review): delegate toggle_outline opening arm to focus_outline --- git-workon-review/src/app.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 02141a0..cd6919a 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -2129,12 +2129,11 @@ impl App { /// `o`: a pure show/hide toggle — closed -> open+focused (+[`Self::sync_outline_to_current`]), /// open (regardless of focus) -> closed+diff-focused. Focus itself is now a separate concern /// handled by [`Self::focus_outline`]/[`Self::focus_diff`] (`h`/`l`) — `o` only ever changes - /// visibility. + /// visibility. The opening arm IS `focus_outline`'s closed-case behavior, so it delegates + /// there rather than restating it. pub fn toggle_outline(&mut self) { if !self.outline.open { - self.outline.open = true; - self.outline.focused = true; - self.sync_outline_to_current(); + self.focus_outline(); } else { self.outline.open = false; self.outline.focused = false; From bd595b329d2cf5060dedd9a1817bcc0651bc2edb Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 17:55:20 -0400 Subject: [PATCH 102/203] feat(review): give the outline a scrolloff viewport and g/G jumps --- git-workon-review/src/app.rs | 258 ++++++++++++++++++++++++++++++++ git-workon-review/src/keymap.rs | 16 ++ git-workon-review/src/render.rs | 21 ++- git-workon-review/src/tui.rs | 33 ++++ 4 files changed, 317 insertions(+), 11 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index cd6919a..3da517f 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -590,6 +590,10 @@ pub struct OutlineState { /// The outline pane's column width — `workon.review.outline.width` (CS7), defaulting to /// [`DEFAULT_OUTLINE_WIDTH`]. Read by `render.rs` in place of the old fixed const. pub width: u16, + /// Top-of-viewport row index into [`App::outline_items`]'s row list, derived from `cursor` + /// via the same scrolloff discipline as [`App::scroll`] (see [`App::derive_outline_scroll`]) — + /// never written directly. + pub scroll: usize, } /// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the @@ -815,6 +819,9 @@ pub struct App { /// [`Self::pane_height`] — [`Self::derive_alt_scroll`] derives the unfocused pane's scroll /// against THIS, not the focused pane's height. pub(crate) alt_height: usize, + /// Content height of the outline pane, written by the renderer each frame — same discipline + /// as [`Self::pane_height`]. Read by [`Self::derive_outline_scroll`]. + pub outline_height: usize, /// Label for the old side of the diff, shown next to a rename's `old_path` in the header. /// M4 only reviews the uncommitted (`HEAD` ↔ worktree) diffs, so this is always `"HEAD"` /// today; M5's committed-changeset zoom will want the changeset's actual base rev. @@ -1017,6 +1024,7 @@ impl App { cursor: 0, mode: OutlineMode::default(), width: DEFAULT_OUTLINE_WIDTH, + scroll: 0, }; let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial @@ -1041,6 +1049,7 @@ impl App { pane_height: 20, alt: PaneState::default(), alt_height: 20, + outline_height: 20, base_label, highlighter: TsHighlighter::new(), layout: Layout::default(), @@ -2115,6 +2124,12 @@ impl App { self.outline.cursor } + /// Top-of-viewport row index into [`Self::outline_items`]'s row list — see + /// [`Self::derive_outline_scroll`]. + pub fn outline_scroll(&self) -> usize { + self.outline.scroll + } + /// The outline pane's column width — `workon.review.outline.width` (CS7), or /// [`DEFAULT_OUTLINE_WIDTH`] if never set. Read by `render.rs` in place of the old fixed /// const. @@ -2237,6 +2252,42 @@ impl App { idx += step; } } + self.derive_outline_scroll(); + } + + /// `g`/`G` while the outline has focus: jump the cursor straight to row `idx` (clamped into + /// the current row list), landing on it in one step — unlike [`Self::outline_move_by`], there + /// is NO burst back-scan here: a jump to a HEADER/DIR row simply doesn't move the diff (`g` + /// typically lands on the stack's first header), and a jump to a FILE row jumps the diff + /// straight there (`G` typically lands on the last file). Used by [`Self::outline_top`]/ + /// [`Self::outline_bottom`]. + fn outline_move_to(&mut self, idx: usize) { + let items = self.outline_items(); + if items.is_empty() { + self.outline.cursor = 0; + self.derive_outline_scroll(); + return; + } + let idx = idx.min(items.len() - 1); + self.outline.cursor = idx; + if let OutlineItem::File { + cs_idx, file_idx, .. + } = &items[idx] + { + self.switch_changeset(*cs_idx, *file_idx); + } + self.derive_outline_scroll(); + } + + /// `g` while the outline has focus: jump the cursor to the first row. + pub fn outline_top(&mut self) { + self.outline_move_to(0); + } + + /// `G` while the outline has focus: jump the cursor to the last row. + pub fn outline_bottom(&mut self) { + let last = self.outline_items().len().saturating_sub(1); + self.outline_move_to(last); } /// `Enter` while the outline has focus: jump the diff to the row under the outline cursor (a @@ -2284,6 +2335,7 @@ impl App { let items = self.outline_items(); if items.is_empty() { self.outline.cursor = 0; + self.derive_outline_scroll(); return; } if let Some(idx) = items.iter().position(|it| { @@ -2297,6 +2349,7 @@ impl App { } else { self.outline.cursor = self.outline.cursor.min(items.len() - 1); } + self.derive_outline_scroll(); } /// Row count of file `idx`'s `role` view in the active layout's space (0 if absent/unloaded). @@ -2353,6 +2406,21 @@ impl App { derive_scroll_value(self.alt.cursor, self.alt.scroll, rows, self.alt_height); } + /// Re-derive the outline pane's `scroll` from its `cursor` — the outline's counterpart to + /// [`Self::derive_scroll`], reusing the same [`derive_scroll_value`] core against + /// [`Self::outline_height`]. Called after every outline-cursor mutation (mirroring how every + /// diff-cursor mutator ends with `derive_scroll`); the renderer also re-derives each frame, + /// which covers resizes. + pub(crate) fn derive_outline_scroll(&mut self) { + let rows = self.outline_items().len(); + self.outline.scroll = derive_scroll_value( + self.outline.cursor, + self.outline.scroll, + rows, + self.outline_height, + ); + } + /// 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 @@ -7496,6 +7564,196 @@ mod tests { assert!(!app.outline_open()); } + // ── CS2: outline scrolloff viewport + g/G jumps ───────────────────────────── + + /// Four committed changesets of three files each — Stack mode (the default) yields 16 rows + /// (header + 3 files, ×4), long enough to exercise [`App::derive_outline_scroll`]'s margin + /// behavior against a small `outline_height`, unlike the 5-row + /// [`two_committed_changesets_two_and_one_files`] fixture used elsewhere in this module. + fn four_committed_changesets_three_files_each() -> App { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut base = fixture + .commit("main") + .file("root.txt", "r\n") + .create("root") + .unwrap(); + let mut changesets = Vec::new(); + for cs_num in 0..4 { + let head = fixture + .commit("main") + .file(&format!("cs{cs_num}_a.txt"), "a\n") + .file(&format!("cs{cs_num}_b.txt"), "b\n") + .file(&format!("cs{cs_num}_c.txt"), "c\n") + .create(&format!("cs{cs_num}")) + .unwrap(); + changesets.push(Changeset { + name: format!("cs-{cs_num}"), + span: ChangesetSpan::Committed { base, head }, + title: None, + current: cs_num == 0, + needs_restack: false, + }); + base = head; + } + let repo = fixture.repo().unwrap(); + let views = changesets + .into_iter() + .map(|cs| { + let diff = crate::acquire::diff_changeset(repo, &cs).unwrap(); + ChangesetView::from_changeset_diff(cs, diff) + }) + .collect(); + + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, views); + app.open_current(); + app.outline.mode = OutlineMode::Stack; + assert_eq!(app.outline_items().len(), 16, "4 x (1 header + 3 files)"); + app + } + + #[test] + fn outline_move_by_keeps_cursor_within_the_scrolloff_margin() { + let mut app = four_committed_changesets_three_files_each(); + app.outline_height = 5; // bottom_margin = 5 - 1 - SCROLLOFF(2) = 2 + app.outline.cursor = 0; + app.derive_outline_scroll(); + assert_eq!(app.outline_scroll(), 0); + + // Walk down one row at a time; the scroll must follow to keep the cursor within + // `[scroll, scroll + bottom_margin]`, never snapping straight to the cursor. + for _ in 0..8 { + app.outline_move_by(1); + let scroll = app.outline_scroll(); + let cursor = app.outline_cursor(); + assert!( + cursor >= scroll && cursor <= scroll + 2, + "cursor {cursor} must stay within the scrolloff-margined viewport at scroll {scroll}" + ); + } + assert!( + app.outline_scroll() > 0, + "scrolling down must have moved the viewport" + ); + + // Walking back up must scroll up minimally, not snap to zero. + let scroll_at_bottom = app.outline_scroll(); + app.outline_move_by(-1); + assert!( + app.outline_scroll() <= scroll_at_bottom, + "moving up must not increase scroll" + ); + assert!( + app.outline_scroll() > 0, + "a single step up from deep in the list must not snap scroll to zero" + ); + } + + #[test] + fn outline_scroll_clamps_at_both_ends() { + let mut app = four_committed_changesets_three_files_each(); + app.outline_height = 5; + + app.outline.cursor = 0; + app.derive_outline_scroll(); + assert_eq!( + app.outline_scroll(), + 0, + "top row 0 must be visible at start" + ); + + let last = app.outline_items().len() - 1; + app.outline.cursor = last; + app.derive_outline_scroll(); + let scroll = app.outline_scroll(); + assert!( + last >= scroll && last < scroll + app.outline_height, + "the last row must be visible once the cursor reaches it" + ); + assert!( + scroll <= app.outline_items().len().saturating_sub(app.outline_height), + "scroll must never run past the point where the last row leaves the viewport" + ); + } + + #[test] + fn outline_top_lands_cursor_zero_and_does_not_jump_a_header() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline_height = 3; + app.next_changeset(); // move the diff off its start so a stray jump would be observable + let (cs_before, file_before) = (app.current_cs(), app.current); + + app.outline.cursor = 4; // b1.txt's row + app.outline_top(); + + assert_eq!(app.outline_cursor(), 0, "g lands on row 0"); + assert!( + matches!(app.outline_items()[0], OutlineItem::Header { .. }), + "row 0 in Stack mode is cs-a's header" + ); + assert_eq!( + (app.current_cs(), app.current), + (cs_before, file_before), + "landing on a Header must not jump the diff" + ); + } + + #[test] + fn outline_bottom_lands_on_the_last_row_and_jumps_a_file() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline_height = 3; + assert_eq!(app.current_cs(), 0, "starts on cs-a"); + + app.outline_bottom(); + + let last = app.outline_items().len() - 1; + assert_eq!(app.outline_cursor(), last, "G lands on the last row"); + assert!( + matches!(app.outline_items()[last], OutlineItem::File { .. }), + "the last row in Stack mode is cs-b's only file, b1.txt" + ); + assert_eq!( + (app.current_cs(), app.current), + (1, 0), + "landing on a File must switch the diff there" + ); + } + + #[test] + fn outline_cycle_mode_and_sync_leave_scroll_consistent() { + let mut app = four_committed_changesets_three_files_each(); + app.outline_height = 4; + // Push the cursor (and scroll) deep into Stack mode's row list first. + for _ in 0..10 { + app.outline_move_by(1); + } + assert!( + app.outline_scroll() > 0, + "precondition: scrolled away from the top" + ); + + app.outline_cycle_mode(); // -> Tree + let cursor = app.outline_cursor(); + let scroll = app.outline_scroll(); + assert!( + cursor >= scroll && cursor < scroll + app.outline_height, + "outline_cycle_mode must leave the cursor visible within the new mode's scroll" + ); + + app.next_changeset(); // diff-initiated nav -> sync_outline_to_current + let cursor = app.outline_cursor(); + let scroll = app.outline_scroll(); + assert!( + cursor >= scroll && cursor < scroll + app.outline_height, + "sync_outline_to_current must leave the cursor visible within scroll" + ); + } + // ── CS7: view-config (`apply_view_config`) ───────────────────────────────── #[test] diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 7cc9bc4..1498b78 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -70,6 +70,8 @@ pub enum Command { OutlineConfirm, OutlineCycleMode, FocusDiff, + OutlineTop, + OutlineBottom, } /// One row of the action registry: a [`Command`] with its stable config identity (`view` + @@ -305,6 +307,20 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "l right", description: "Focus the diff view", }, + Registered { + command: Command::OutlineTop, + view: View::Outline, + name: "scroll-top", + default_keys: "g", + description: "Jump to the top of the outline", + }, + Registered { + command: Command::OutlineBottom, + view: View::Outline, + name: "scroll-bottom", + default_keys: "G", + description: "Jump to the bottom of the outline", + }, ]; /// One matchable key press: a [`KeyCode`] plus whether Ctrl/Alt are required. **Shift is diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 2bf2d88..59829fb 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -472,20 +472,19 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// the path. 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 -/// legible even after focus returns to the diff). -fn render_outline(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { +/// 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) { + app.outline_height = area.height as usize; + app.derive_outline_scroll(); + let items = app.outline_items(); let cursor = app.outline_cursor(); let focused = app.outline_focused(); - - let visible_h = area.height as usize; - let scroll = if visible_h == 0 { - 0 - } else if cursor >= visible_h { - cursor + 1 - visible_h - } else { - 0 - }; + let scroll = app.outline_scroll(); let buf = frame.buffer_mut(); for row in 0..area.height { diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 30f49ab..8ac8079 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -419,6 +419,8 @@ enum Action { OutlineCycleMode, FocusOutline, FocusDiff, + OutlineTop, + OutlineBottom, None, } @@ -459,6 +461,8 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::OutlineCycleMode => Action::OutlineCycleMode, Command::FocusOutline => Action::FocusOutline, Command::FocusDiff => Action::FocusDiff, + Command::OutlineTop => Action::OutlineTop, + Command::OutlineBottom => Action::OutlineBottom, } } @@ -572,6 +576,8 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::OutlineCycleMode => app.outline_cycle_mode(), Action::FocusOutline => app.focus_outline(), Action::FocusDiff => app.focus_diff(), + Action::OutlineTop => app.outline_top(), + Action::OutlineBottom => app.outline_bottom(), Action::None => {} } false @@ -1301,6 +1307,33 @@ mod tests { ); } + #[test] + fn g_and_shift_g_map_to_outline_top_and_bottom_when_outline_focused() { + // CS2: `g`/`G` are bound per-view (`scroll-top`/`scroll-bottom` in both View::Diff and + // View::Outline), so the SAME key must resolve to a different Action depending on which + // pane has focus — outline-focused maps to the outline jump, not the diff scroll. + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + assert_eq!( + map_key(&km, &mut pending, key(KeyCode::Char('g')), 20, true, true), + Action::OutlineTop + ); + assert_eq!( + map_key(&km, &mut pending, key(KeyCode::Char('G')), 20, true, true), + Action::OutlineBottom + ); + // Diff-focused (`outline_focused = false`) still maps to the diff's own scroll actions, + // even with the outline open — see `g_and_shift_g_map_to_top_and_bottom` above. + assert_eq!( + map_key(&km, &mut pending, key(KeyCode::Char('g')), 20, false, true), + Action::ScrollTop + ); + assert_eq!( + map_key(&km, &mut pending, key(KeyCode::Char('G')), 20, false, true), + Action::ScrollBottom + ); + } + #[test] fn shift_l_maps_to_toggle_layout() { let km = Keymap::defaults(); From 787a34c4ae3d1a5bd273054bea9e1a8846c7fee3 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 22:53:52 -0400 Subject: [PATCH 103/203] fix(review): pass row count into derive_outline_scroll --- git-workon-review/src/app.rs | 23 ++++++++++++----------- git-workon-review/src/render.rs | 4 ++-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 3da517f..a209ce5 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -2252,7 +2252,7 @@ impl App { idx += step; } } - self.derive_outline_scroll(); + self.derive_outline_scroll(items.len()); } /// `g`/`G` while the outline has focus: jump the cursor straight to row `idx` (clamped into @@ -2265,7 +2265,7 @@ impl App { let items = self.outline_items(); if items.is_empty() { self.outline.cursor = 0; - self.derive_outline_scroll(); + self.derive_outline_scroll(0); return; } let idx = idx.min(items.len() - 1); @@ -2276,7 +2276,7 @@ impl App { { self.switch_changeset(*cs_idx, *file_idx); } - self.derive_outline_scroll(); + self.derive_outline_scroll(items.len()); } /// `g` while the outline has focus: jump the cursor to the first row. @@ -2335,7 +2335,7 @@ impl App { let items = self.outline_items(); if items.is_empty() { self.outline.cursor = 0; - self.derive_outline_scroll(); + self.derive_outline_scroll(0); return; } if let Some(idx) = items.iter().position(|it| { @@ -2349,7 +2349,7 @@ impl App { } else { self.outline.cursor = self.outline.cursor.min(items.len() - 1); } - self.derive_outline_scroll(); + self.derive_outline_scroll(items.len()); } /// Row count of file `idx`'s `role` view in the active layout's space (0 if absent/unloaded). @@ -2410,9 +2410,10 @@ impl App { /// [`Self::derive_scroll`], reusing the same [`derive_scroll_value`] core against /// [`Self::outline_height`]. Called after every outline-cursor mutation (mirroring how every /// diff-cursor mutator ends with `derive_scroll`); the renderer also re-derives each frame, - /// which covers resizes. - pub(crate) fn derive_outline_scroll(&mut self) { - let rows = self.outline_items().len(); + /// which covers resizes. Takes the outline row count from the caller — every call site has + /// just built (or is about to paint from) [`Self::outline_items`], and rebuilding the whole + /// snapshot here again just for `.len()` would double the work on every keypress and frame. + pub(crate) fn derive_outline_scroll(&mut self, rows: usize) { self.outline.scroll = derive_scroll_value( self.outline.cursor, self.outline.scroll, @@ -7620,7 +7621,7 @@ mod tests { let mut app = four_committed_changesets_three_files_each(); app.outline_height = 5; // bottom_margin = 5 - 1 - SCROLLOFF(2) = 2 app.outline.cursor = 0; - app.derive_outline_scroll(); + app.derive_outline_scroll(app.outline_items().len()); assert_eq!(app.outline_scroll(), 0); // Walk down one row at a time; the scroll must follow to keep the cursor within @@ -7658,7 +7659,7 @@ mod tests { app.outline_height = 5; app.outline.cursor = 0; - app.derive_outline_scroll(); + app.derive_outline_scroll(app.outline_items().len()); assert_eq!( app.outline_scroll(), 0, @@ -7667,7 +7668,7 @@ mod tests { let last = app.outline_items().len() - 1; app.outline.cursor = last; - app.derive_outline_scroll(); + app.derive_outline_scroll(app.outline_items().len()); let scroll = app.outline_scroll(); assert!( last >= scroll && last < scroll + app.outline_height, diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 59829fb..f850c61 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -479,9 +479,9 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// transient bottom-anchor scroll computed fresh each frame. fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { app.outline_height = area.height as usize; - app.derive_outline_scroll(); - let items = app.outline_items(); + app.derive_outline_scroll(items.len()); + let cursor = app.outline_cursor(); let focused = app.outline_focused(); let scroll = app.outline_scroll(); From 4155a4f2cd3be2faae34801d9cdf22b645ae8e97 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 18:27:09 -0400 Subject: [PATCH 104/203] feat(review): order outline head-first with dirs before files --- git-workon-review/src/app.rs | 120 +++++++++++- git-workon-review/src/config.rs | 14 ++ git-workon-review/src/outline.rs | 325 ++++++++++++++++++++++++------- git-workon-review/src/render.rs | 22 +-- git-workon-review/src/tui.rs | 4 + 5 files changed, 397 insertions(+), 88 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index a209ce5..c49a614 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -22,7 +22,7 @@ use crate::config::RawViewConfig; use crate::highlight::{FgSpan, TsHighlighter}; use crate::model::{DiffModel, FileChange, FileStatus, Hunk, LineKind}; use crate::ops; -use crate::outline::{self, OutlineChangeset, OutlineFile, OutlineItem, OutlineMode}; +use crate::outline::{self, OutlineChangeset, OutlineFile, OutlineItem, OutlineMode, OutlineOrder}; use crate::queue::{OpOutcome, StagingOp, StagingQueue}; use crate::refresh::{IndexSignature, RefreshCoordinator}; use crate::source::{resolve_source, Source}; @@ -552,6 +552,17 @@ fn parse_outline_mode(raw: &str) -> Option { } } +/// 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, + } +} + /// 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. @@ -594,6 +605,9 @@ pub struct OutlineState { /// via the same scrolloff discipline as [`App::scroll`] (see [`App::derive_outline_scroll`]) — /// never written directly. pub scroll: usize, + /// Which end of the stack the stack-shaped modes display first — `workon.review.outline.order` + /// (CS3), defaulting to [`OutlineOrder::HeadFirst`]. Read by [`App::outline_items`]. + pub order: OutlineOrder, } /// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the @@ -1025,6 +1039,7 @@ impl App { mode: OutlineMode::default(), width: DEFAULT_OUTLINE_WIDTH, scroll: 0, + order: OutlineOrder::default(), }; let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial @@ -2109,7 +2124,7 @@ impl App { .collect(), }) .collect(); - outline::build_items(&snapshot, self.outline.mode) + outline::build_items(&snapshot, self.outline.mode, self.outline.order) } pub fn outline_open(&self) -> bool { @@ -2141,6 +2156,12 @@ impl App { self.outline.mode } + /// Which end of the stack the outline displays first — `workon.review.outline.order` (CS3), + /// or [`OutlineOrder::default`] if never set. + pub fn outline_order(&self) -> OutlineOrder { + self.outline.order + } + /// `o`: a pure show/hide toggle — closed -> open+focused (+[`Self::sync_outline_to_current`]), /// open (regardless of focus) -> closed+diff-focused. Focus itself is now a separate concern /// handled by [`Self::focus_outline`]/[`Self::focus_diff`] (`h`/`l`) — `o` only ever changes @@ -2205,6 +2226,14 @@ impl App { self.outline.mode = mode; } + /// Set the outline stack order directly — the config-startup (CS3) counterpart there is no + /// interactive key for today. Same non-resync posture as [`Self::set_outline_mode`]: called + /// before the first [`Self::open_current`], so no [`Self::sync_outline_to_current`] call is + /// needed here either. + pub fn set_outline_order(&mut self, order: OutlineOrder) { + self.outline.order = order; + } + /// Move the outline's own cursor by `delta` rows (`j`/`k` while the outline has focus), /// clamped into the current row list. Landing on a FILE row jumps the diff there /// immediately (outline -> diff, per the locked design); a HEADER/DIR row itself never @@ -2590,6 +2619,17 @@ impl App { }; 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() + }), + None => OutlineOrder::default(), + }; + self.set_outline_order(order); + let layout = match &raw.diff_layout { Some(l) => parse_diff_layout(l).unwrap_or_else(|| { warnings.push(format!( @@ -3469,7 +3509,7 @@ mod tests { use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::ReviewConfig; use crate::model::FileStatus; - use crate::outline::{OutlineItem, OutlineMode, StagedStatus}; + use crate::outline::{OutlineItem, OutlineMode, OutlineOrder, StagedStatus}; #[test] fn combined_files_arrive_path_sorted() { @@ -6988,6 +7028,9 @@ mod tests { let owned = Repository::open(repo.workdir().unwrap()).unwrap(); let mut app = App::from_changesets(owned, vec![view_a, view_b]); app.outline.mode = OutlineMode::Stack; + // CS3: pin BaseFirst explicitly — this test asserts per-header marker content, not + // display order, so it doesn't need to track the new HeadFirst default. + app.outline.order = OutlineOrder::BaseFirst; let items = app.outline_items(); assert_eq!( @@ -7093,6 +7136,10 @@ mod tests { let repo = Repository::open(fixture.repo().unwrap().workdir().unwrap()).unwrap(); let mut app = App::from_changesets(repo, vec![view_pending, view_failed]); app.outline.mode = OutlineMode::Stack; + // CS3: pin BaseFirst explicitly — this test asserts the exact header vec, which is + // incidental to base -> head storage order here, not what's under test (the + // loading/failed markers). + app.outline.order = OutlineOrder::BaseFirst; let items = app.outline_items(); assert_eq!( @@ -7247,6 +7294,10 @@ mod tests { let owned = Repository::open(repo.workdir().unwrap()).unwrap(); let mut app = App::from_changesets(owned, vec![view_a, view_b]); app.outline.mode = OutlineMode::Stack; + // CS3: pin BaseFirst explicitly — the regression this test guards needs cs-a BEFORE + // cs-b in the row list (an earlier row's insertion shifting a later row's index); the + // new HeadFirst default would put cs-b (head) first instead, inverting the scenario. + app.outline.order = OutlineOrder::BaseFirst; assert_eq!( app.current_cs(), 1, @@ -7323,6 +7374,9 @@ mod tests { fn outline_move_by_on_a_file_row_jumps_the_diff() { let mut app = two_committed_changesets_two_and_one_files(); app.outline.mode = OutlineMode::Flat; + // CS3: pin BaseFirst explicitly — this test exercises `outline_move_by`'s row-crossing + // mechanics via hardcoded Flat-mode indices, not display order. + app.outline.order = OutlineOrder::BaseFirst; app.outline.cursor = 0; assert_eq!(app.current_cs(), 0); assert_eq!(app.current, 0); @@ -7342,6 +7396,10 @@ mod tests { fn outline_move_by_on_a_header_row_does_not_jump_the_diff() { let mut app = two_committed_changesets_two_and_one_files(); app.outline.mode = OutlineMode::Stack; + // CS3: pin BaseFirst explicitly — this test's hardcoded row indices assume base -> head + // order (header, a1, a2, header, b1); the new HeadFirst default is a display-order + // concern orthogonal to what's under test here (whether a header move jumps the diff). + app.outline.order = OutlineOrder::BaseFirst; // Header rows sit at indices 0 (cs-a) and 3 (cs-b) in Stack mode (header, a1, a2, // header, b1). Park the diff on a2, cursor on its row. app.outline.cursor = 2; @@ -7367,11 +7425,16 @@ mod tests { // header row (the LAST file crossed, exactly where unit presses leave it). let mut coalesced = two_committed_changesets_two_and_one_files(); coalesced.outline.mode = OutlineMode::Stack; + // CS3: pin BaseFirst explicitly — the burst-vs-sequential equivalence under test doesn't + // depend on which end of the stack displays first, and the inline comments below assume + // base -> head row order. + coalesced.outline.order = OutlineOrder::BaseFirst; coalesced.outline.cursor = 0; coalesced.outline_move_by(3); // header -> a1 -> a2 -> cs-b header let mut sequential = two_committed_changesets_two_and_one_files(); sequential.outline.mode = OutlineMode::Stack; + sequential.outline.order = OutlineOrder::BaseFirst; sequential.outline.cursor = 0; for _ in 0..3 { sequential.outline_move_by(1); @@ -7395,6 +7458,9 @@ mod tests { fn outline_confirm_on_a_header_row_jumps_to_its_first_file_and_returns_focus() { let mut app = two_committed_changesets_two_and_one_files(); app.outline.mode = OutlineMode::Stack; + // CS3: pin BaseFirst explicitly — cursor 3 is hardcoded to cs-b's header under base -> + // head row order; the confirm mechanic under test is order-agnostic. + app.outline.order = OutlineOrder::BaseFirst; app.outline.open = true; app.outline.focused = true; app.outline.cursor = 3; // cs-b's header row @@ -7682,19 +7748,24 @@ mod tests { #[test] fn outline_top_lands_cursor_zero_and_does_not_jump_a_header() { + // CS3: the outline's default order is now HeadFirst, so Stack mode's row 0 is cs-b's + // (the head changeset's) header, not cs-a's — see + // `stack_mode_head_first_shows_last_changesets_header_first_with_true_cs_idx` in + // outline.rs for the row-order pin. `outline_top`'s own contract (row 0, no diff jump) + // is order-agnostic, so only the "which header" framing below changes. let mut app = two_committed_changesets_two_and_one_files(); app.outline.mode = OutlineMode::Stack; app.outline_height = 3; app.next_changeset(); // move the diff off its start so a stray jump would be observable let (cs_before, file_before) = (app.current_cs(), app.current); - app.outline.cursor = 4; // b1.txt's row + app.outline.cursor = 4; // a2.txt's row under head-first order (cs-a's last file) app.outline_top(); assert_eq!(app.outline_cursor(), 0, "g lands on row 0"); assert!( matches!(app.outline_items()[0], OutlineItem::Header { .. }), - "row 0 in Stack mode is cs-a's header" + "row 0 in Stack mode is a header (cs-b's, the head changeset, under head-first order)" ); assert_eq!( (app.current_cs(), app.current), @@ -7705,6 +7776,9 @@ mod tests { #[test] fn outline_bottom_lands_on_the_last_row_and_jumps_a_file() { + // CS3: under the new HeadFirst default, Stack mode's row order is cs-b's header/file(s) + // first, then cs-a's — so the LAST row is cs-a's last file (a2.txt, cs_idx 0, file_idx + // 1), not cs-b's only file as it was under the old base-first order. let mut app = two_committed_changesets_two_and_one_files(); app.outline.mode = OutlineMode::Stack; app.outline_height = 3; @@ -7716,11 +7790,11 @@ mod tests { assert_eq!(app.outline_cursor(), last, "G lands on the last row"); assert!( matches!(app.outline_items()[last], OutlineItem::File { .. }), - "the last row in Stack mode is cs-b's only file, b1.txt" + "the last row in Stack mode under head-first order is cs-a's last file, a2.txt" ); assert_eq!( (app.current_cs(), app.current), - (1, 0), + (0, 1), "landing on a File must switch the diff there" ); } @@ -7768,6 +7842,7 @@ mod tests { assert!(warnings.is_empty()); assert_eq!(app.outline_width(), DEFAULT_OUTLINE_WIDTH); assert_eq!(app.outline_mode(), OutlineMode::default()); + assert_eq!(app.outline_order(), OutlineOrder::default()); assert_eq!(app.layout, Layout::default()); assert_eq!(app.zoom, Zoom::default()); } @@ -7834,6 +7909,37 @@ mod tests { assert!(warnings[0].contains("outline.mode")); } + #[test] + fn outline_order_overrides_default_when_set() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.order", "base-first") + .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.outline_order(), OutlineOrder::BaseFirst); + } + + #[test] + fn outline_order_invalid_falls_back_to_default_with_warning() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.order", "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.outline_order(), OutlineOrder::default()); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("outline.order")); + } + #[test] fn diff_layout_overrides_default_when_set() { let fixture = FixtureBuilder::new() diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index 045b3e4..6353809 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -31,6 +31,7 @@ //! [workon "review.outline"] //! width = 32 //! mode = tree +//! order = base-first ; head-first | base-first (default: head-first) //! //! [workon "review.diff"] //! layout = split @@ -101,6 +102,7 @@ pub struct RawBinding { pub struct RawViewConfig { pub outline_width: Option, pub outline_mode: Option, + pub outline_order: Option, pub diff_layout: Option, pub diff_zoom: Option, } @@ -203,6 +205,11 @@ impl<'repo> ReviewConfig<'repo> { self.get_view_string(View::Outline, "mode") } + /// Get `workon.review.outline.order`, raw. `None` if unset. + pub fn outline_order(&self) -> Result, git2::Error> { + self.get_view_string(View::Outline, "order") + } + /// Get `workon.review.diff.layout`, raw. `None` if unset. pub fn diff_layout(&self) -> Result, git2::Error> { self.get_view_string(View::Diff, "layout") @@ -224,6 +231,7 @@ impl<'repo> ReviewConfig<'repo> { RawViewConfig { outline_width: self.outline_width().ok().flatten(), outline_mode: self.outline_mode().ok().flatten(), + outline_order: self.outline_order().ok().flatten(), diff_layout: self.diff_layout().ok().flatten(), diff_zoom: self.diff_zoom().ok().flatten(), } @@ -407,6 +415,7 @@ mod tests { let fixture = FixtureBuilder::new() .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") .build() @@ -419,6 +428,10 @@ mod tests { config.outline_mode().expect("mode"), Some("tree".to_string()) ); + assert_eq!( + config.outline_order().expect("order"), + Some("base-first".to_string()) + ); assert_eq!( config.diff_layout().expect("layout"), Some("split".to_string()) @@ -437,6 +450,7 @@ mod tests { assert_eq!(config.outline_width().expect("width"), None); assert_eq!(config.outline_mode().expect("mode"), None); + assert_eq!(config.outline_order().expect("order"), None); assert_eq!(config.diff_layout().expect("layout"), None); assert_eq!(config.diff_zoom().expect("zoom"), None); } diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index f045a03..283b056 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -43,6 +43,20 @@ impl OutlineMode { } } +/// Which end of the stack the outline's stack-shaped modes ([`OutlineMode::Stack`]/ +/// [`OutlineMode::StackTree`]) display first — CS3 dogfooding feedback #2. Purely a display +/// order: [`OutlineItem`]'s `cs_idx`/`file_idx` always stay TRUE indices into `App::changesets` +/// regardless of which way the rows are painted (see [`build_items`]'s doc comment). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OutlineOrder { + /// The most recently created (head) changeset's header renders first — the CS3 default. + #[default] + HeadFirst, + /// The stack's base changeset renders first, matching `App::changesets`' own base -> head + /// storage order (today's pre-CS3 behavior). + BaseFirst, +} + /// A file's staged-ness for the outline's status column — a minimal indicator (locked CS3 /// scope: NOT the prototype's X/Y two-column git-status matrix). Only meaningful for the /// uncommitted changeset's files; a committed changeset's files always resolve to `None` @@ -175,23 +189,37 @@ impl OutlineItem { } } -/// Build the outline's row list for `mode` from every reviewed changeset, in the same base -> -/// head order `App::changesets` holds them. -pub fn build_items(changesets: &[OutlineChangeset], mode: OutlineMode) -> Vec { +/// Build the outline's row list for `mode` from every reviewed changeset. `order` controls which +/// end of the stack displays first for the stack-shaped modes (see [`OutlineOrder`]); `cs_idx`/ +/// `file_idx` on every emitted [`OutlineItem`] are always TRUE indices into `App::changesets` +/// (that array's own base -> head storage order never changes) regardless of `order` — only the +/// ROW SEQUENCE the outline paints flips. [`build_tree`]'s de-dupe is order-independent (see its +/// own doc comment), so `order` is accepted but unused there. +pub fn build_items( + changesets: &[OutlineChangeset], + mode: OutlineMode, + order: OutlineOrder, +) -> Vec { match mode { - OutlineMode::Flat => build_flat(changesets), - OutlineMode::Stack => build_stack(changesets), + OutlineMode::Flat => build_flat(changesets, order), + OutlineMode::Stack => build_stack(changesets, order), OutlineMode::Tree => build_tree(changesets), - OutlineMode::StackTree => build_stack_tree(changesets), + OutlineMode::StackTree => build_stack_tree(changesets, order), } } /// [`OutlineMode::Stack`]: a header per changeset, then its files in order — no de-duplication, /// every changeset's own copy of a path (if touched more than once across the stack) gets its -/// own row under its own header. -fn build_stack(changesets: &[OutlineChangeset]) -> Vec { +/// own row under its own header. `order` picks which end of the stack paints first; `cs_idx`/ +/// `file_idx` are computed from the ORIGINAL (base -> head) enumeration before any reversal, so +/// they stay true indices into `App::changesets` either way. +fn build_stack(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec { + let mut entries: Vec<(usize, &OutlineChangeset)> = changesets.iter().enumerate().collect(); + if order == OutlineOrder::HeadFirst { + entries.reverse(); + } let mut items = Vec::new(); - for (cs_idx, cs) in changesets.iter().enumerate() { + for (cs_idx, cs) in entries { items.push(OutlineItem::Header { cs_idx, label: cs.label.clone(), @@ -213,24 +241,31 @@ fn build_stack(changesets: &[OutlineChangeset]) -> Vec { items } -/// [`OutlineMode::Flat`]: every changed path once, in FIRST-appearance order (a stable, readable -/// order that doesn't reshuffle just because a later changeset re-touches an earlier path), but -/// pointing at its LAST (newest / closest-to-head) occurrence — "last-write-wins" per the locked -/// design: a path touched by both an earlier committed changeset and the uncommitted layer -/// should jump to (and show the staged-ness of) the uncommitted layer's copy, not the stale -/// committed one. -fn build_flat(changesets: &[OutlineChangeset]) -> Vec { - let mut order: Vec = Vec::new(); - let mut latest: HashMap = HashMap::new(); - for (cs_idx, cs) in changesets.iter().enumerate() { - for (file_idx, file) in cs.files.iter().enumerate() { - if !latest.contains_key(&file.path) { - order.push(file.path.clone()); +/// [`OutlineMode::Flat`]: every changed path once, in FIRST-appearance order UNDER `order`'s +/// display scan (a stable, readable order that doesn't reshuffle just because a later-scanned +/// changeset re-touches an earlier path), but pointing at its closest-to-head occurrence — +/// "last-write-wins" per the locked design: a path touched by both an earlier committed +/// changeset and the uncommitted layer should jump to (and show the staged-ness of) the +/// uncommitted layer's copy, not the stale committed one. This head-wins target resolution is +/// independent of `order` — [`latest_by_path`] always scans base -> head regardless of which way +/// the row list is displayed, so the resolution below reuses it rather than re-deriving from the +/// (possibly reversed) `order` scan used for display order. +fn build_flat(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec { + let latest = latest_by_path(changesets); + let mut entries: Vec<(usize, &OutlineChangeset)> = changesets.iter().enumerate().collect(); + if order == OutlineOrder::HeadFirst { + entries.reverse(); + } + let mut order_list: Vec = Vec::new(); + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for (_, cs) in entries { + for file in &cs.files { + if seen.insert(file.path.as_str()) { + order_list.push(file.path.clone()); } - latest.insert(file.path.clone(), (cs_idx, file_idx, file.status)); } } - order + order_list .into_iter() .map(|path| { let (cs_idx, file_idx, status) = latest[&path]; @@ -266,7 +301,7 @@ fn latest_by_path( #[derive(Debug, Default)] struct TrieNode { file: Option<(usize, usize, StagedStatus)>, - /// Insertion order is irrelevant — [`emit`] re-sorts children (dirs-after-files, alpha + /// Insertion order is irrelevant — [`emit`] re-sorts children (dirs-before-files, alpha /// within group) every time it flattens a node. children: Vec<(String, TrieNode)>, } @@ -293,12 +328,11 @@ impl TrieNode { } } -/// Flatten `node`'s children into `items`, depth-first, in "dirs after files at each level, -/// alpha within group" order (matches the `~/.config/nvim/lua/app/review/ui/outline.lua` -/// prototype's `_build_path_tree`/`_emit_tree_node`: files read before directories at a given -/// level, so a directory's own contents don't visually separate its sibling files from the -/// directory listing above them). `ancestors_last` is the growing guide vector — see -/// [`OutlineItem`]'s doc comment for how rendering consumes it. +/// Flatten `node`'s children into `items`, depth-first, in "dirs before files at each level, +/// alpha within group" order (CS3 dogfooding feedback #7: directories read before files at a +/// given level, matching the conventional file-tree convention of grouping folders above +/// loose files). `ancestors_last` is the growing guide vector — see [`OutlineItem`]'s doc +/// comment for how rendering consumes it. fn emit(node: &TrieNode, ancestors_last: &[bool], items: &mut Vec) { let mut files: Vec<&(String, TrieNode)> = node .children @@ -312,7 +346,7 @@ fn emit(node: &TrieNode, ancestors_last: &[bool], items: &mut Vec) .collect(); files.sort_by(|a, b| a.0.cmp(&b.0)); dirs.sort_by(|a, b| a.0.cmp(&b.0)); - let ordered: Vec<&(String, TrieNode)> = files.into_iter().chain(dirs).collect(); + let ordered: Vec<&(String, TrieNode)> = dirs.into_iter().chain(files).collect(); let n = ordered.len(); for (i, (name, child)) in ordered.into_iter().enumerate() { let is_last = i == n - 1; @@ -340,7 +374,10 @@ fn emit(node: &TrieNode, ancestors_last: &[bool], items: &mut Vec) } /// [`OutlineMode::Tree`]: [`build_flat`]'s de-duped path set, rendered as a single directory -/// trie spanning the whole stack (no changeset headers). +/// trie spanning the whole stack (no changeset headers). Alpha-sorted by path segment at every +/// level ([`emit`]), not by stack position, and [`latest_by_path`]'s de-dupe always resolves to +/// the closest-to-head occurrence regardless of scan order — so [`OutlineOrder`] has nothing to +/// affect here, and unlike the stack-shaped builders this one takes no `order` parameter. fn build_tree(changesets: &[OutlineChangeset]) -> Vec { let latest = latest_by_path(changesets); let mut root = TrieNode::default(); @@ -356,10 +393,15 @@ fn build_tree(changesets: &[OutlineChangeset]) -> Vec { /// [`OutlineMode::StackTree`]: [`build_stack`]'s per-changeset header grouping, but each /// changeset's own files are flattened into their own nested trie (no cross-changeset dedup — /// each changeset trie is built from just that changeset's files, matching `build_stack`'s "every -/// changeset's own copy gets its own row" rule). -fn build_stack_tree(changesets: &[OutlineChangeset]) -> Vec { +/// changeset's own copy gets its own row" rule). `order` picks which end of the stack paints +/// first, same as [`build_stack`]; `cs_idx`/`file_idx` stay true indices regardless. +fn build_stack_tree(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec { + let mut entries: Vec<(usize, &OutlineChangeset)> = changesets.iter().enumerate().collect(); + if order == OutlineOrder::HeadFirst { + entries.reverse(); + } let mut items = Vec::new(); - for (cs_idx, cs) in changesets.iter().enumerate() { + for (cs_idx, cs) in entries { items.push(OutlineItem::Header { cs_idx, label: cs.label.clone(), @@ -423,7 +465,10 @@ mod tests { cs("cs-a", false, false, &[("a1.txt", StagedStatus::None)]), cs("cs-b", true, true, &[("b1.txt", StagedStatus::None)]), ]; - let items = build_items(&changesets, OutlineMode::Stack); + // BaseFirst pins the base -> head structural rule (header-then-files per changeset) + // independent of display order; head-first order coverage lives in the dedicated + // `stack_mode_*_order` tests below. + let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst); assert_eq!( items, vec![ @@ -469,7 +514,7 @@ mod tests { cs_slot("cs-pending", true, false), cs_slot("cs-failed", false, true), ]; - let items = build_items(&changesets, OutlineMode::Stack); + let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst); assert_eq!( items, vec![ @@ -506,7 +551,7 @@ mod tests { ("a2.txt", StagedStatus::None), ], )]; - let items = build_items(&changesets, OutlineMode::Flat); + let items = build_items(&changesets, OutlineMode::Flat, OutlineOrder::HeadFirst); assert!(items .iter() .all(|it| matches!(it, OutlineItem::File { .. }))); @@ -524,7 +569,7 @@ mod tests { &[("shared.txt", StagedStatus::Unstaged)], ), ]; - let items = build_items(&changesets, OutlineMode::Flat); + let items = build_items(&changesets, OutlineMode::Flat, OutlineOrder::HeadFirst); assert_eq!(items.len(), 1, "the shared path must appear exactly once"); assert_eq!( items[0], @@ -553,7 +598,10 @@ mod tests { ), cs("cs-b", true, false, &[("shared.txt", StagedStatus::Staged)]), ]; - let items = build_items(&changesets, OutlineMode::Flat); + // BaseFirst scans cs-a before cs-b, so "first appearance" here means base -> head scan + // order; the head-first display-order variant lives in + // `flat_mode_head_first_scans_head_to_base_but_keeps_head_wins_target` below. + let items = build_items(&changesets, OutlineMode::Flat, OutlineOrder::BaseFirst); let paths: Vec<&str> = items .iter() .map(|it| match it { @@ -568,6 +616,53 @@ mod tests { ); } + /// CS3: [`OutlineOrder::HeadFirst`] flips [`build_flat`]'s DISPLAY scan (first-appearance + /// order now reads head -> base), but [`latest_by_path`]'s "closest-to-head wins" TARGET + /// resolution never changes — a path touched by two changesets must resolve to the head-most + /// one under BOTH orders. + #[test] + fn flat_mode_head_first_scans_head_to_base_but_keeps_head_wins_target() { + let changesets = vec![ + cs( + "cs-a", + false, + false, + &[ + ("first.txt", StagedStatus::None), + ("shared.txt", StagedStatus::None), + ], + ), + cs("cs-b", true, false, &[("shared.txt", StagedStatus::Staged)]), + ]; + let items = build_items(&changesets, OutlineMode::Flat, OutlineOrder::HeadFirst); + let paths: Vec<&str> = items + .iter() + .map(|it| match it { + OutlineItem::File { path, .. } => path.as_str(), + OutlineItem::Header { .. } | OutlineItem::Dir { .. } => unreachable!(), + }) + .collect(); + assert_eq!( + paths, + vec!["shared.txt", "first.txt"], + "head-first display scans cs-b (head) before cs-a (base), so shared.txt is seen \ + first" + ); + assert_eq!( + items + .iter() + .find(|it| matches!(it, OutlineItem::File { path, .. } if path == "shared.txt")), + Some(&OutlineItem::File { + cs_idx: 1, + file_idx: 0, + path: "shared.txt".to_string(), + status: StagedStatus::Staged, + guides: Vec::new(), + }), + "target resolution stays head-wins (cs-b) regardless of display order" + ); + } + #[test] fn staged_status_from_flags_covers_the_truth_table() { assert_eq!(StagedStatus::from_flags(false, false), StagedStatus::None); @@ -589,7 +684,7 @@ mod tests { /// Deep-path fixture used by the tree-mode tests: a top-level file, a top-level directory /// with both its own file and a nested subdirectory of two more files — enough to exercise - /// depth > 1 and the dirs-after-files/alpha-within-group ordering at every level. + /// depth > 1 and the dirs-before-files/alpha-within-group ordering at every level. fn deep_path_changeset(label: &str, current: bool, needs_restack: bool) -> OutlineChangeset { cs( label, @@ -605,56 +700,56 @@ mod tests { } #[test] - fn tree_mode_builds_dirs_after_files_alpha_within_group_with_correct_depth_and_guides() { + fn tree_mode_builds_dirs_before_files_alpha_within_group_with_correct_depth_and_guides() { let changesets = vec![deep_path_changeset("cs-a", true, false)]; - let items = build_items(&changesets, OutlineMode::Tree); + let items = build_items(&changesets, OutlineMode::Tree, OutlineOrder::HeadFirst); assert_eq!( items, vec![ - OutlineItem::File { - cs_idx: 0, - file_idx: 0, - path: "top.rs".to_string(), - status: StagedStatus::None, - guides: vec![false], - }, OutlineItem::Dir { name: "src".to_string(), - guides: vec![true], - }, - OutlineItem::File { - cs_idx: 0, - file_idx: 3, - path: "d.rs".to_string(), - status: StagedStatus::None, - guides: vec![true, false], + guides: vec![false], }, OutlineItem::Dir { name: "a".to_string(), - guides: vec![true, true], + guides: vec![false, false], }, OutlineItem::File { cs_idx: 0, file_idx: 1, path: "b.rs".to_string(), status: StagedStatus::None, - guides: vec![true, true, false], + guides: vec![false, false, false], }, OutlineItem::File { cs_idx: 0, file_idx: 2, path: "c.rs".to_string(), status: StagedStatus::None, - guides: vec![true, true, true], + guides: vec![false, false, true], + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 3, + path: "d.rs".to_string(), + status: StagedStatus::None, + guides: vec![false, true], + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 0, + path: "top.rs".to_string(), + status: StagedStatus::None, + guides: vec![true], }, ], - "root: top.rs (file) then src/ (dir); under src/: d.rs (file) then a/ (dir); \ - under src/a/: b.rs then c.rs — files-before-dirs, alpha within each group" + "root: src/ (dir) then top.rs (file); under src/: a/ (dir) then d.rs (file); \ + under src/a/: b.rs then c.rs — dirs-before-files, alpha within each group" ); - assert_eq!(items[0].depth(), 0, "top.rs is a root-level row"); - assert_eq!(items[1].depth(), 0, "src/ is a root-level row"); - assert_eq!(items[2].depth(), 1, "src/d.rs is one level deep"); - assert_eq!(items[4].depth(), 2, "src/a/b.rs is two levels deep"); + assert_eq!(items[0].depth(), 0, "src/ is a root-level row"); + assert_eq!(items[1].depth(), 1, "src/a/ is one level deep"); + assert_eq!(items[2].depth(), 2, "src/a/b.rs is two levels deep"); + assert_eq!(items[5].depth(), 0, "top.rs is a root-level row"); } #[test] @@ -663,7 +758,7 @@ mod tests { cs("cs-a", false, false, &[("shared.txt", StagedStatus::None)]), cs("cs-b", true, false, &[("shared.txt", StagedStatus::Staged)]), ]; - let items = build_items(&changesets, OutlineMode::Tree); + let items = build_items(&changesets, OutlineMode::Tree, OutlineOrder::HeadFirst); assert_eq!( items, vec![OutlineItem::File { @@ -683,7 +778,7 @@ mod tests { cs("cs-a", false, false, &[("x/y.txt", StagedStatus::None)]), cs("cs-b", true, true, &[("z.txt", StagedStatus::Unstaged)]), ]; - let items = build_items(&changesets, OutlineMode::StackTree); + let items = build_items(&changesets, OutlineMode::StackTree, OutlineOrder::BaseFirst); assert_eq!( items, vec![ @@ -726,4 +821,94 @@ mod tests { with no cross-changeset dedup" ); } + + /// CS3: [`OutlineOrder::HeadFirst`] (the new default) shows the LAST changeset's ([`cs-c`], + /// index 2 — the true, base-> head `App::changesets` index) header FIRST, while its `cs_idx` + /// still equals its true index into `changesets` (2), never a display-order index (0). + #[test] + fn stack_mode_head_first_shows_last_changesets_header_first_with_true_cs_idx() { + let changesets = vec![ + cs("cs-a", false, false, &[("a1.txt", StagedStatus::None)]), + cs("cs-b", false, false, &[("b1.txt", StagedStatus::None)]), + cs("cs-c", true, false, &[("c1.txt", StagedStatus::None)]), + ]; + let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::HeadFirst); + assert_eq!( + items[0], + OutlineItem::Header { + cs_idx: 2, + label: "cs-c".to_string(), + current: true, + needs_restack: false, + loading: false, + failed: false, + }, + "head-first: the LAST (head) changeset's header renders first, carrying its TRUE \ + index (2) into `changesets`, not a display-order index" + ); + let labels: Vec<&str> = items + .iter() + .filter_map(|it| match it { + OutlineItem::Header { label, .. } => Some(label.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + labels, + vec!["cs-c", "cs-b", "cs-a"], + "head-first header order is exactly the reverse of `changesets`' base -> head order" + ); + } + + /// CS3: [`OutlineOrder::BaseFirst`] restores the pre-CS3 base -> head header order. + #[test] + fn stack_mode_base_first_restores_base_to_head_header_order() { + let changesets = vec![ + cs("cs-a", false, false, &[("a1.txt", StagedStatus::None)]), + cs("cs-b", true, false, &[("b1.txt", StagedStatus::None)]), + ]; + let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst); + let labels: Vec<&str> = items + .iter() + .filter_map(|it| match it { + OutlineItem::Header { label, .. } => Some(label.as_str()), + _ => None, + }) + .collect(); + assert_eq!(labels, vec!["cs-a", "cs-b"]); + } + + /// [`OutlineMode::StackTree`] analog of + /// `stack_mode_head_first_shows_last_changesets_header_first_with_true_cs_idx`. + #[test] + fn stack_tree_mode_head_first_shows_last_changesets_header_first_with_true_cs_idx() { + let changesets = vec![ + cs("cs-a", false, false, &[("x/y.txt", StagedStatus::None)]), + cs("cs-b", true, false, &[("z.txt", StagedStatus::None)]), + ]; + let items = build_items(&changesets, OutlineMode::StackTree, OutlineOrder::HeadFirst); + assert_eq!( + items[0], + OutlineItem::Header { + cs_idx: 1, + label: "cs-b".to_string(), + current: true, + needs_restack: false, + loading: false, + failed: false, + }, + "head-first: cs-b's header renders first, carrying its true index (1)" + ); + assert_eq!( + items[1], + OutlineItem::File { + cs_idx: 1, + file_idx: 0, + path: "z.txt".to_string(), + status: StagedStatus::None, + guides: vec![true], + }, + "cs-b's own file follows immediately under its head-first header" + ); + } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index f850c61..38ff4b1 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -2557,24 +2557,24 @@ mod tests { let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); - // Row order per the dirs-after-files/alpha-within-group rule, one outline row per - // buffer row starting at y=1 (y=0 is the winbar): `top.txt` (file, root, NOT the root's - // last child — `src/` follows), `src/` (dir, root, IS the root's last child), then - // `a.txt` nested one level under `src/` (the only — hence last — child of `src/`). + // Row order per the CS3 dirs-before-files/alpha-within-group rule, one outline row per + // buffer row starting at y=1 (y=0 is the winbar): `src/` (dir, root, NOT the root's last + // child — `top.txt` follows), `a.txt` nested one level under `src/` (the only — hence + // last — child of `src/`), then `top.txt` (file, root, IS the root's last child). assert!( - content[1].contains('\u{251C}') && content[1].contains("top.txt"), - "expected row 1 to be top.txt with a non-last '├─' guide, got:\n{}", + content[1].contains('\u{251C}') && content[1].contains("src/"), + "expected row 1 to be the src/ directory row with a non-last '├─' guide, got:\n{}", content.join("\n") ); assert!( - content[2].contains('\u{2514}') && content[2].contains("src/"), - "expected row 2 to be the src/ directory row with a last-child '└─' guide, got:\n{}", + content[2].contains('\u{2514}') && content[2].contains("a.txt"), + "expected row 2 to be src/a.txt, indented under src/ with its own last-child '└─' \ + guide, got:\n{}", content.join("\n") ); assert!( - content[3].contains('\u{2514}') && content[3].contains("a.txt"), - "expected row 3 to be src/a.txt, indented under src/ with its own last-child '└─' \ - guide, got:\n{}", + content[3].contains('\u{2514}') && content[3].contains("top.txt"), + "expected row 3 to be top.txt with a last-child '└─' guide, got:\n{}", content.join("\n") ); } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 8ac8079..86d99a4 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -2178,6 +2178,10 @@ mod tests { .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); + // CS3: pin BaseFirst explicitly — this test exercises Enter's header-jump + focus + // return, which is orthogonal to display order, but the `-3` row offset below assumes + // the base->head row layout. + app.set_outline_order(workon_review::outline::OutlineOrder::BaseFirst); app.toggle_outline(); // close app.toggle_outline(); // open + focus, cursor synced onto cs-b's file row assert!(app.outline_focused()); From ee1c80dd4ab88de9b6ac903a4d9125a9eb6d30b3 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 22:57:16 -0400 Subject: [PATCH 105/203] fix(review): extract shared scan_order for stack display order --- git-workon-review/src/outline.rs | 33 +++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 283b056..2cea63a 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -57,6 +57,21 @@ pub enum OutlineOrder { BaseFirst, } +/// Enumerate `changesets` in `order`'s display scan — the shared preamble of every stack-shaped +/// builder below. Indices are always TRUE base -> head indices into the slice regardless of scan +/// direction (enumerate happens BEFORE any reversal), which is the invariant `cs_idx`/`file_idx` +/// consumers like `App::switch_changeset` rely on. +fn scan_order( + changesets: &[OutlineChangeset], + order: OutlineOrder, +) -> Vec<(usize, &OutlineChangeset)> { + let mut entries: Vec<(usize, &OutlineChangeset)> = changesets.iter().enumerate().collect(); + if order == OutlineOrder::HeadFirst { + entries.reverse(); + } + entries +} + /// A file's staged-ness for the outline's status column — a minimal indicator (locked CS3 /// scope: NOT the prototype's X/Y two-column git-status matrix). Only meaningful for the /// uncommitted changeset's files; a committed changeset's files always resolve to `None` @@ -214,12 +229,8 @@ pub fn build_items( /// `file_idx` are computed from the ORIGINAL (base -> head) enumeration before any reversal, so /// they stay true indices into `App::changesets` either way. fn build_stack(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec { - let mut entries: Vec<(usize, &OutlineChangeset)> = changesets.iter().enumerate().collect(); - if order == OutlineOrder::HeadFirst { - entries.reverse(); - } let mut items = Vec::new(); - for (cs_idx, cs) in entries { + for (cs_idx, cs) in scan_order(changesets, order) { items.push(OutlineItem::Header { cs_idx, label: cs.label.clone(), @@ -252,13 +263,9 @@ fn build_stack(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec Vec { let latest = latest_by_path(changesets); - let mut entries: Vec<(usize, &OutlineChangeset)> = changesets.iter().enumerate().collect(); - if order == OutlineOrder::HeadFirst { - entries.reverse(); - } let mut order_list: Vec = Vec::new(); let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); - for (_, cs) in entries { + for (_, cs) in scan_order(changesets, order) { for file in &cs.files { if seen.insert(file.path.as_str()) { order_list.push(file.path.clone()); @@ -396,12 +403,8 @@ fn build_tree(changesets: &[OutlineChangeset]) -> Vec { /// changeset's own copy gets its own row" rule). `order` picks which end of the stack paints /// first, same as [`build_stack`]; `cs_idx`/`file_idx` stay true indices regardless. fn build_stack_tree(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec { - let mut entries: Vec<(usize, &OutlineChangeset)> = changesets.iter().enumerate().collect(); - if order == OutlineOrder::HeadFirst { - entries.reverse(); - } let mut items = Vec::new(); - for (cs_idx, cs) in entries { + for (cs_idx, cs) in scan_order(changesets, order) { items.push(OutlineItem::Header { cs_idx, label: cs.label.clone(), From b930adfc83c83b8f5f119086d898292e33a74b8d Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 18:54:51 -0400 Subject: [PATCH 106/203] feat(review): summary panel for outline header and dir rows --- git-workon-review/src/app.rs | 276 +++++++++++++++++++++++++++-- git-workon-review/src/lib.rs | 1 + git-workon-review/src/outline.rs | 57 ++++-- git-workon-review/src/render.rs | 276 ++++++++++++++++++++++++++++- git-workon-review/src/summary.rs | 289 +++++++++++++++++++++++++++++++ 5 files changed, 877 insertions(+), 22 deletions(-) create mode 100644 git-workon-review/src/summary.rs diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index c49a614..9b46d63 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -27,6 +27,7 @@ use crate::queue::{OpOutcome, StagingOp, StagingQueue}; use crate::refresh::{IndexSignature, RefreshCoordinator}; use crate::source::{resolve_source, Source}; use crate::stage_op::{FileStagingOp, LineSelectionOp}; +use crate::summary; use crate::synthesis::LineSelection; use crate::wordiff::{word_diff_spans, Span}; @@ -587,6 +588,30 @@ fn parse_diff_zoom(raw: &str) -> Option { } } +/// 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. +/// `render.rs`'s `render_summary` never matches on this directly — it only calls +/// `App::summary_for`/renders the [`Summary`] that comes back. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SummaryTarget { + /// The cursor rests on an [`OutlineItem::Header`] row — `cs_idx` is that row's true index + /// into [`App::changesets`]. + Changeset(usize), + /// The cursor rests on an [`OutlineItem::Dir`] row — `path` is that row's full path, `cs_idx` + /// its `cs_idx` (`Some` in [`OutlineMode::StackTree`], `None` in the cross-stack + /// [`OutlineMode::Tree`] — see that field's doc comment on [`OutlineItem::Dir`]). + Dir { cs_idx: Option, path: String }, +} + +/// CS4: the renderable summary [`App::summary_for`] builds for a [`SummaryTarget`] — a thin +/// wrapper so `render.rs` has one return type to match on regardless of which kind of row was +/// selected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Summary { + Changeset(summary::ChangesetSummary), + Dir(summary::DirSummary), +} + /// The outline side pane's own state (locked fork 3): whether it's showing, whether IT (rather /// than the diff) currently has keyboard focus, its own cursor (an index into /// [`App::outline_items`]'s row list — a wholly separate coordinate space from [`App::cursor`]), @@ -2098,14 +2123,14 @@ impl App { // ── Outline side pane (CS3) ───────────────────────────────────────────────── - /// Snapshot every reviewed changeset into [`OutlineChangeset`]/[`OutlineFile`] and build the - /// current [`OutlineMode`]'s row list — the outline cursor's index space, and the source of - /// truth `render.rs` draws from. Rebuilt fresh on every call (cheap: a small stack times a - /// handful of files each, no caching, same posture as [`Self::effective_zoom_for`]) rather - /// than cached on `App`, so it's never stale across a mode toggle, a nav, or a refresh. - pub fn outline_items(&self) -> Vec { - let snapshot: Vec = self - .changesets + // ── Summary panel (CS4) ───────────────────────────────────────────────────── + + /// Snapshot every reviewed changeset into [`OutlineChangeset`]/[`OutlineFile`] — the input + /// [`Self::outline_items`] feeds `outline::build_items`, and CS4's [`Self::summary_for`] + /// feeds `outline::latest_by_path` for a [`OutlineMode::Tree`] directory's cross-stack + /// aggregate. Rebuilt fresh on every call, same posture as [`Self::outline_items`] itself. + fn outline_snapshot(&self) -> Vec { + self.changesets .iter() .map(|v| OutlineChangeset { label: v.cs.title.clone().unwrap_or_else(|| v.cs.name.clone()), @@ -2123,10 +2148,93 @@ impl App { }) .collect(), }) - .collect(); + .collect() + } + + /// Build the current [`OutlineMode`]'s row list — the outline cursor's index space, and the + /// source of truth `render.rs` draws from. Rebuilt fresh on every call (cheap: a small stack + /// times a handful of files each, no caching, same posture as [`Self::effective_zoom_for`]) + /// rather than cached on `App`, so it's never stale across a mode toggle, a nav, or a + /// refresh. + pub fn outline_items(&self) -> Vec { + let snapshot = self.outline_snapshot(); outline::build_items(&snapshot, self.outline.mode, self.outline.order) } + /// CS4: the outline row a Header/Dir cursor selection resolves to — `None` when the outline + /// isn't in a state where the diff area shows a summary instead of a file's diff (closed, + /// merely open-but-unfocused, or the cursor is on a File row). `render_body` branches on this + /// before any of its usual diff-body gates (pending/failed/binary/deferred-load). + pub fn summary_target(&self) -> Option { + if !self.outline.open || !self.outline.focused { + return None; + } + let items = self.outline_items(); + match items.get(self.outline.cursor)? { + OutlineItem::Header { cs_idx, .. } => Some(SummaryTarget::Changeset(*cs_idx)), + OutlineItem::Dir { path, cs_idx, .. } => Some(SummaryTarget::Dir { + cs_idx: *cs_idx, + path: path.clone(), + }), + OutlineItem::File { .. } => None, + } + } + + /// Build the renderable summary for `target` (see [`Self::summary_target`]) — + /// `render::render_summary`'s data source. + pub fn summary_for(&self, target: SummaryTarget) -> Summary { + match target { + SummaryTarget::Changeset(cs_idx) => { + let view = &self.changesets[cs_idx]; + let label = view + .cs + .title + .clone() + .unwrap_or_else(|| view.cs.name.clone()); + let failure_message = view.failure_message().map(|s| s.to_string()); + Summary::Changeset(summary::changeset_summary( + label, + view.cs.current, + view.cs.needs_restack, + view.is_pending(), + view.is_failed(), + failure_message, + view.files(), + )) + } + SummaryTarget::Dir { + cs_idx: Some(cs_idx), + path, + } => { + // StackTree mode: the dir row's trie belongs to exactly one changeset, so scope + // the aggregate to that changeset's own files (mirrors `build_stack_tree`'s "no + // cross-changeset dedup" rule). + let view = &self.changesets[cs_idx]; + Summary::Dir(summary::dir_summary(path, view.files())) + } + SummaryTarget::Dir { cs_idx: None, path } => { + // Tree mode: the dir row's trie spans the whole stack with no single owning + // changeset — aggregate over the same last-write-wins de-duped path set the Tree + // outline itself displays, reusing `outline::latest_by_path` rather than + // re-deriving the dedup rule here. `latest_by_path` returns a `HashMap`, whose + // iteration order is unspecified — sort by path so the panel's file list reads in + // the same alpha order the Tree outline itself paints (`emit`'s own sort). + let snapshot = self.outline_snapshot(); + let latest = outline::latest_by_path(&snapshot); + let mut entries: Vec<(&String, &(usize, usize, outline::StagedStatus))> = + latest.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + let files: Vec = entries + .into_iter() + .filter_map(|(_, &(cs_idx, file_idx, _))| { + self.changesets[cs_idx].files().get(file_idx).cloned() + }) + .collect(); + Summary::Dir(summary::dir_summary(path, &files)) + } + } + } + pub fn outline_open(&self) -> bool { self.outline.open } @@ -3504,7 +3612,8 @@ 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, Layout, LoadedViews, Role, Severity, Zoom, DEFAULT_OUTLINE_WIDTH, + EffectiveZoom, Layout, LoadedViews, Role, Severity, Summary, SummaryTarget, Zoom, + DEFAULT_OUTLINE_WIDTH, }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::ReviewConfig; @@ -8001,4 +8110,151 @@ mod tests { assert_eq!(warnings.len(), 1); assert!(warnings[0].contains("diff.zoom")); } + + // ── CS4: summary panel ─────────────────────────────────────────────────────── + + /// Force the outline open+focused with `mode` and `cursor`, matching the state + /// `summary_target` requires — the individual state-transition tests below build off this + /// instead of repeating the three-field setup. Pins `order` to `BaseFirst` so a fixture's + /// base -> head file/changeset indices line up with display order (the default `HeadFirst` + /// reverses the header row sequence — irrelevant to what's under test here, see CS3). + fn open_focused_outline(app: &mut App, mode: OutlineMode, cursor: usize) { + app.outline.open = true; + app.outline.focused = true; + app.outline.mode = mode; + app.outline.cursor = cursor; + app.outline.order = OutlineOrder::BaseFirst; + } + + #[test] + fn summary_target_is_none_when_the_outline_is_closed() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.open = false; + app.outline.focused = false; + assert_eq!(app.summary_target(), None); + } + + #[test] + fn summary_target_is_none_when_the_outline_is_open_but_unfocused() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.open = true; + app.outline.focused = false; + app.outline.mode = OutlineMode::Stack; + app.outline.cursor = 0; // a Header row + assert_eq!( + app.summary_target(), + None, + "an unfocused open outline must never override the diff area (locked design)" + ); + } + + #[test] + fn summary_target_is_none_when_the_cursor_is_on_a_file_row() { + let mut app = two_committed_changesets_two_and_one_files(); + open_focused_outline(&mut app, OutlineMode::Stack, 1); // cs-a's first file row + let items = app.outline_items(); + assert!(matches!(items[1], OutlineItem::File { .. })); + assert_eq!(app.summary_target(), None); + } + + #[test] + fn summary_target_is_some_changeset_on_a_header_row() { + let mut app = two_committed_changesets_two_and_one_files(); + open_focused_outline(&mut app, OutlineMode::Stack, 0); // cs-a's header row + let items = app.outline_items(); + assert!(matches!(items[0], OutlineItem::Header { cs_idx: 0, .. })); + assert_eq!(app.summary_target(), Some(SummaryTarget::Changeset(0))); + } + + #[test] + fn summary_target_is_some_dir_with_cs_idx_none_in_tree_mode() { + let mut app = single_changeset_with_nested_paths(); + let items = { + app.outline.mode = OutlineMode::Tree; + app.outline_items() + }; + let dir_idx = items + .iter() + .position(|it| matches!(it, OutlineItem::Dir { name, .. } if name == "src")) + .expect("src/ dir row present in Tree mode"); + open_focused_outline(&mut app, OutlineMode::Tree, dir_idx); + assert_eq!( + app.summary_target(), + Some(SummaryTarget::Dir { + cs_idx: None, + path: "src".to_string(), + }), + "Tree mode's single cross-stack trie has no owning changeset" + ); + } + + #[test] + fn summary_target_is_some_dir_with_cs_idx_some_in_stack_tree_mode() { + let mut app = single_changeset_with_nested_paths(); + let items = { + app.outline.mode = OutlineMode::StackTree; + app.outline_items() + }; + let dir_idx = items + .iter() + .position(|it| matches!(it, OutlineItem::Dir { name, .. } if name == "src")) + .expect("src/ dir row present in StackTree mode"); + open_focused_outline(&mut app, OutlineMode::StackTree, dir_idx); + assert_eq!( + app.summary_target(), + Some(SummaryTarget::Dir { + cs_idx: Some(0), + path: "src".to_string(), + }), + "StackTree mode's dir row belongs to the single changeset in this fixture" + ); + } + + #[test] + fn summary_target_returns_none_again_after_focus_diff() { + let mut app = two_committed_changesets_two_and_one_files(); + open_focused_outline(&mut app, OutlineMode::Stack, 0); + assert!(app.summary_target().is_some()); + app.focus_diff(); + assert_eq!( + app.summary_target(), + None, + "losing outline focus must immediately fall back to the diff body" + ); + } + + #[test] + fn summary_for_changeset_reflects_the_changesets_flags_and_files() { + let mut app = two_committed_changesets_two_and_one_files(); + open_focused_outline(&mut app, OutlineMode::Stack, 0); + let target = app.summary_target().unwrap(); + let Summary::Changeset(summary) = app.summary_for(target) else { + panic!("expected a Changeset summary for a Header target"); + }; + assert!(summary.current, "cs-a is the current changeset"); + assert!(!summary.needs_restack); + assert!(!summary.loading); + assert!(!summary.failed); + assert_eq!(summary.files.len(), 2, "cs-a touches a1.txt and a2.txt"); + assert!(summary.total_adds + summary.total_dels > 0); + } + + #[test] + fn summary_for_dir_in_tree_mode_aggregates_the_deduped_cross_stack_set() { + let mut app = single_changeset_with_nested_paths(); + app.outline.mode = OutlineMode::Tree; + let items = app.outline_items(); + let dir_idx = items + .iter() + .position(|it| matches!(it, OutlineItem::Dir { name, .. } if name == "src")) + .unwrap(); + open_focused_outline(&mut app, OutlineMode::Tree, dir_idx); + let target = app.summary_target().unwrap(); + let Summary::Dir(summary) = app.summary_for(target) else { + panic!("expected a Dir summary for a Dir target"); + }; + assert_eq!(summary.path, "src"); + let paths: Vec<&str> = summary.files.iter().map(|r| r.path.as_str()).collect(); + assert_eq!(paths, vec!["src/a.txt", "src/b.txt"]); + } } diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index cff5f73..985babc 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -31,6 +31,7 @@ pub mod refresh; pub mod render; pub mod source; pub mod stage_op; +pub mod summary; pub mod synthesis; pub mod terminal_query; pub mod theme; diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 2cea63a..a490904 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -173,11 +173,24 @@ pub enum OutlineItem { failed: bool, }, /// A directory row — only emitted in [`OutlineMode::Tree`]/[`OutlineMode::StackTree`]. Not a - /// jump target: it carries no `cs_idx`/`file_idx`, so `App::outline_move_by` no-ops on it - /// (same as [`Self::Header`]) and `App::outline_confirm` also no-ops on it (CS4 decision — - /// there's no expand/collapse state to toggle, so Enter on a directory row does nothing but - /// still returns focus to the diff, matching every other confirm outcome). - Dir { name: String, guides: Vec }, + /// jump target: it carries no `file_idx`, so `App::outline_move_by` no-ops on it (same as + /// [`Self::Header`]) and `App::outline_confirm` also no-ops on it (CS4 decision — there's no + /// expand/collapse state to toggle, so Enter on a directory row does nothing but still + /// returns focus to the diff, matching every other confirm outcome). + Dir { + name: String, + /// The FULL path from the trie root (e.g. `"src/cmd"`), unlike `name` which is just the + /// leaf segment — CS4's summary panel needs the whole path to filter files under this + /// directory (see `crate::summary::dir_summary`). + path: String, + /// `Some(cs_idx)` when this row's trie is per-changeset ([`OutlineMode::StackTree`] — + /// the same true index its owning [`Self::Header`] carries); `None` in the cross-stack + /// [`OutlineMode::Tree`], whose single trie spans every changeset (so a dir row there has + /// no single owning changeset to scope a summary to — CS4's `App::summary_for` instead + /// aggregates over [`latest_by_path`]'s de-duped set for that case). + cs_idx: Option, + guides: Vec, + }, /// A file row — the target of every outline->diff jump. `path` is the FULL path in /// [`OutlineMode::Flat`]/[`OutlineMode::Stack`] (unchanged from CS3), but is just the leaf /// segment in [`OutlineMode::Tree`]/[`OutlineMode::StackTree`] — the ancestor directory rows @@ -290,7 +303,12 @@ fn build_flat(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec HashMap { let mut latest = HashMap::new(); @@ -340,7 +358,13 @@ impl TrieNode { /// given level, matching the conventional file-tree convention of grouping folders above /// loose files). `ancestors_last` is the growing guide vector — see [`OutlineItem`]'s doc /// comment for how rendering consumes it. -fn emit(node: &TrieNode, ancestors_last: &[bool], items: &mut Vec) { +fn emit( + node: &TrieNode, + ancestors_last: &[bool], + path_prefix: &str, + dir_cs_idx: Option, + items: &mut Vec, +) { let mut files: Vec<&(String, TrieNode)> = node .children .iter() @@ -370,11 +394,18 @@ fn emit(node: &TrieNode, ancestors_last: &[bool], items: &mut Vec) }); } None => { + let full_path = if path_prefix.is_empty() { + name.clone() + } else { + format!("{path_prefix}/{name}") + }; items.push(OutlineItem::Dir { name: name.clone(), + path: full_path.clone(), + cs_idx: dir_cs_idx, guides: guides.clone(), }); - emit(child, &guides, items); + emit(child, &guides, &full_path, dir_cs_idx, items); } } } @@ -393,7 +424,7 @@ fn build_tree(changesets: &[OutlineChangeset]) -> Vec { root.insert(&segments, *cs_idx, *file_idx, *status); } let mut items = Vec::new(); - emit(&root, &[], &mut items); + emit(&root, &[], "", None, &mut items); items } @@ -418,7 +449,7 @@ fn build_stack_tree(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec let segments: Vec<&str> = file.path.split('/').collect(); root.insert(&segments, cs_idx, file_idx, file.status); } - emit(&root, &[], &mut items); + emit(&root, &[], "", Some(cs_idx), &mut items); } items } @@ -711,10 +742,14 @@ mod tests { vec![ OutlineItem::Dir { name: "src".to_string(), + path: "src".to_string(), + cs_idx: None, guides: vec![false], }, OutlineItem::Dir { name: "a".to_string(), + path: "src/a".to_string(), + cs_idx: None, guides: vec![false, false], }, OutlineItem::File { @@ -795,6 +830,8 @@ mod tests { }, OutlineItem::Dir { name: "x".to_string(), + path: "x".to_string(), + cs_idx: Some(0), guides: vec![true], }, OutlineItem::File { diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 38ff4b1..eb43df6 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -13,13 +13,16 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; use crate::align::{CellKind, DisplayRow, InlineRow, Row}; -use crate::app::{App, EffectiveZoom, FileView, Layout as AppLayout, Notice, Role, Severity}; +use crate::app::{ + App, EffectiveZoom, FileView, Layout as AppLayout, Notice, Role, Severity, Summary, +}; use crate::attribute::Attribution; use crate::config::View; use crate::highlight::FgSpan; use crate::keymap::{footer_hint, help_sections, Keymap}; use crate::model::FileStatus; use crate::outline::OutlineItem; +use crate::summary::{ChangesetSummary, DirSummary, SummaryFileRow}; use crate::theme::Palette; use crate::wordiff::Span as WordSpan; @@ -561,7 +564,7 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette) -> Line<'static> { } Line::from(spans) } - OutlineItem::Dir { name, guides } => { + OutlineItem::Dir { name, guides, .. } => { let text = format!("{}{name}/", tree_prefix(guides)); Line::from(TSpan::styled( text, @@ -773,7 +776,177 @@ 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 +/// 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::raw(" "), + TSpan::styled( + format!("-{}", row.dels), + Style::default().fg(theme.del_strong), + ), + ])); +} + +/// Append `rows`' file lines to `lines`, truncated to leave room for `budget` more rows within the +/// panel's height — the last line becomes `"… and N more"` (dim) when the list overflows instead +/// of silently clipping. +fn push_summary_file_rows( + lines: &mut Vec>, + rows: &[SummaryFileRow], + budget: usize, + theme: &Palette, +) { + if rows.len() <= budget { + for row in rows { + push_summary_file_row(lines, row, theme); + } + return; + } + // Reserve the last visible row for the "… and N more" marker. + let shown = budget.saturating_sub(1); + for row in &rows[..shown] { + push_summary_file_row(lines, row, theme); + } + let remaining = rows.len() - shown; + lines.push(Line::from(TSpan::styled( + format!("\u{2026} and {remaining} more"), + Style::default().fg(theme.dim), + ))); +} + +/// Build a [`ChangesetSummary`]'s lines: title line (carrying the same current/needs-restack/ +/// failed markers `build_outline_line`'s Header arm draws), a loading/failed line OR the per-file +/// list + totals line. +fn changeset_summary_lines( + summary: &ChangesetSummary, + height: usize, + theme: &Palette, +) -> Vec> { + let mut lines = Vec::new(); + + let mut title_spans = vec![TSpan::styled( + if summary.current { "\u{25CF} " } else { " " }, + Style::default().fg(FG_CURRENT), + )]; + title_spans.push(TSpan::styled( + summary.label.clone(), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), + )); + if summary.needs_restack { + title_spans.push(TSpan::styled(" \u{26A0}", Style::default().fg(FG_WARN))); + } + lines.push(Line::from(title_spans)); + + if summary.failed { + let msg = summary + .failure_message + .as_deref() + .unwrap_or("(no error message)"); + lines.push(Line::from(TSpan::styled( + format!("\u{2717} {msg}"), + Style::default().fg(FG_ERROR), + ))); + return lines; + } + if summary.loading { + lines.push(Line::from(TSpan::styled( + "Loading\u{2026}", + Style::default().fg(theme.dim), + ))); + return lines; + } + + lines.push(Line::from("")); + let footer_budget = 1; // the totals line always shows + let file_budget = height.saturating_sub(lines.len() + footer_budget); + push_summary_file_rows(&mut lines, &summary.files, file_budget, theme); + lines.push(Line::from(vec![ + TSpan::styled( + format!("{} files", summary.files.len()), + Style::default().fg(theme.foreground), + ), + TSpan::raw(" "), + TSpan::styled( + format!("+{}", summary.total_adds), + Style::default().fg(theme.add_strong), + ), + TSpan::raw(" "), + TSpan::styled( + format!("-{}", summary.total_dels), + Style::default().fg(theme.del_strong), + ), + ])); + 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). +fn dir_summary_lines(summary: &DirSummary, height: usize, theme: &Palette) -> Vec> { + let mut lines = vec![Line::from(TSpan::styled( + format!("{}/", summary.path), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), + ))]; + lines.push(Line::from("")); + let footer_budget = 1; + let file_budget = height.saturating_sub(lines.len() + footer_budget); + push_summary_file_rows(&mut lines, &summary.files, file_budget, theme); + lines.push(Line::from(vec![ + TSpan::styled( + format!("{} files", summary.files.len()), + Style::default().fg(theme.foreground), + ), + TSpan::raw(" "), + TSpan::styled( + format!("+{}", summary.total_adds), + Style::default().fg(theme.add_strong), + ), + TSpan::raw(" "), + TSpan::styled( + format!("-{}", summary.total_dels), + Style::default().fg(theme.del_strong), + ), + ])); + 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`]). +fn render_summary(frame: &mut Frame, summary: &Summary, area: Rect, theme: &Palette) { + let height = area.height as usize; + let lines = match summary { + Summary::Changeset(cs) => changeset_summary_lines(cs, height, theme), + Summary::Dir(dir) => dir_summary_lines(dir, height, theme), + }; + frame.render_widget(Paragraph::new(lines), area); +} + fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { + // 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() { + let summary = app.summary_for(target); + render_summary(frame, &summary, area, theme); + return; + } // ADR-031: 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 @@ -1291,6 +1464,7 @@ mod tests { use crate::app::test_support::app_from_fixture; use crate::app::App; use crate::keymap::Keymap; + use crate::outline::OutlineItem; use crate::theme::Palette; /// Render one frame against the default (unrebound) keymap and the dark theme — the vast @@ -2579,6 +2753,104 @@ mod tests { ); } + // ── CS4: summary panel ─────────────────────────────────────────────────────── + + /// The body area's columns, for a render at [`OUTLINE_TEST_WIDTH`] (outline `0..35`, divider + /// `35`, body `36..`) — mirrors [`outline_row`]'s slice but for the OTHER side of the pane. + fn body_text(buf: &Buffer) -> String { + (0..buf.area.height) + .map(|y| { + (36..buf.area.width) + .map(|x| cell_text(buf, x, y)) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[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); + assert!(matches!( + app.outline_items()[app.outline_cursor()], + OutlineItem::Header { cs_idx: 1, .. } + )); + app.focus_outline(); // outline_move_by doesn't touch focus; ensure it's still focused + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let body = body_text(&buf); + assert!( + body.contains("cs-b"), + "expected the summary panel's title (cs-b's label — it has no title, so falls back \ + to its name), got:\n{body}" + ); + assert!( + body.contains("+1") && body.contains("-0"), + "expected a '+N -M' diffstat fragment for cs-b's single added file, got:\n{body}" + ); + assert!( + body.contains("1 files"), + "expected the summary panel's totals line, got:\n{body}" + ); + } + + #[test] + fn unfocused_open_outline_on_a_header_row_still_renders_the_normal_diff_body() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + // Default state: open, UNFOCUSED — must NOT show the summary panel (locked design: only + // a FOCUSED outline overrides the diff area) even with the cursor moved onto a Header + // row (construction's `sync_outline_to_current` parks it on cs-b's File row by default). + 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; + let delta = header_idx - app.outline_cursor() as i64; + app.outline_move_by(delta); + assert!(matches!( + app.outline_items()[app.outline_cursor()], + OutlineItem::Header { cs_idx: 1, .. } + )); + assert!( + !app.outline_focused(), + "outline_move_by must not itself grant focus" + ); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let body = body_text(&buf); + assert!( + !body.contains("1 files"), + "an unfocused open outline must never override the diff body with the summary \ + panel's totals line, got:\n{body}" + ); + } + // ── theming fix: canvas paint ──────────────────────────────────────────────── #[test] diff --git a/git-workon-review/src/summary.rs b/git-workon-review/src/summary.rs new file mode 100644 index 0000000..25996bf --- /dev/null +++ b/git-workon-review/src/summary.rs @@ -0,0 +1,289 @@ +//! CS4's summary panel: pure builders for the renderable data `render.rs`'s `render_summary` +//! paints when the outline is OPEN AND FOCUSED and its cursor rests on a +//! [`crate::outline::OutlineItem::Header`]/[`crate::outline::OutlineItem::Dir`] row instead of a +//! file — mirrors [`crate::outline`]'s pure-module posture (no [`crate::app::App`]/git2 +//! dependency): everything here is built from `&[FileChange]`-shaped inputs plus a handful of +//! primitives `App::summary_for` supplies. +//! +//! ## What a changeset summary can show +//! +//! `workon::Changeset` (see `git-workon-lib/src/changeset.rs`) exposes only `name`/`title` for a +//! changeset today — no commit body/message. [`changeset_summary`] therefore renders the +//! label (title, falling back to name — the same rule the winbar/outline header already use) +//! plus the diffstat; there is no commit-message row. Surfacing the commit body would need a +//! `repo.find_commit` lookup keyed off the changeset's head OID — left as a follow-up, not part +//! of this changeset's scope. + +use crate::model::{FileChange, LineKind}; + +/// Count added/deleted LINES across `change`'s hunks — `(adds, dels)`. A binary file (no hunks) +/// counts as `(0, 0)`; `LineKind::Context` lines never count toward either total. +pub fn file_diffstat(change: &FileChange) -> (usize, usize) { + let mut adds = 0usize; + let mut dels = 0usize; + for hunk in &change.hunks { + for line in &hunk.lines { + match line.kind { + LineKind::Addition => adds += 1, + LineKind::Deletion => dels += 1, + LineKind::Context => {} + } + } + } + (adds, dels) +} + +/// One file's row in a summary panel's per-file list — just enough to render `"path +N -M"`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SummaryFileRow { + pub path: String, + pub adds: usize, + pub dels: usize, +} + +/// Build the per-file row list plus its `(total_adds, total_dels)` from `files` — shared by +/// [`changeset_summary`] and [`dir_summary`], the only difference between the two being which +/// files the caller has already filtered down to. +fn file_rows(files: &[FileChange]) -> (Vec, usize, usize) { + let rows: Vec = files + .iter() + .map(|f| { + let (adds, dels) = file_diffstat(f); + SummaryFileRow { + path: f.path.clone(), + adds, + dels, + } + }) + .collect(); + let total_adds = rows.iter().map(|r| r.adds).sum(); + let total_dels = rows.iter().map(|r| r.dels).sum(); + (rows, total_adds, total_dels) +} + +/// Renderable summary for a Header-row outline selection: the changeset's own flags/label (the +/// same fields [`crate::outline::OutlineChangeset`] carries) plus a per-file diffstat breakdown. +/// `loading`/`failed` mirror ADR-031's slot state — when either is set, `files` is always empty +/// (a `Pending`/`Failed` [`crate::app::ChangesetView`] never has a real file list), so +/// `render_summary` shows the loading/failure line in place of the file rows rather than an +/// empty list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangesetSummary { + pub label: String, + pub current: bool, + pub needs_restack: bool, + pub loading: bool, + pub failed: bool, + /// The acquisition failure message (ADR-031), `Some` only when `failed`. + pub failure_message: Option, + pub files: Vec, + pub total_adds: usize, + pub total_dels: usize, +} + +/// Build a [`ChangesetSummary`] from a changeset's outline-relevant fields plus its file list. +#[allow(clippy::too_many_arguments)] +pub fn changeset_summary( + label: String, + current: bool, + needs_restack: bool, + loading: bool, + failed: bool, + failure_message: Option, + files: &[FileChange], +) -> ChangesetSummary { + let (files, total_adds, total_dels) = file_rows(files); + ChangesetSummary { + label, + current, + needs_restack, + loading, + failed, + failure_message, + files, + total_adds, + total_dels, + } +} + +/// Renderable summary for a Dir-row outline selection: the aggregate diffstat for every file +/// under `path`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirSummary { + pub path: String, + pub files: Vec, + pub total_adds: usize, + pub total_dels: usize, +} + +/// Segment-boundary match: `file_path` is "under" `dir_path` only when `dir_path` is a full path +/// SEGMENT prefix of `file_path` — `"src"` matches `"src/a.rs"` but must NOT match `"src2/b.rs"` +/// (a raw [`str::starts_with`] would wrongly match the latter). +fn path_is_under(file_path: &str, dir_path: &str) -> bool { + file_path + .strip_prefix(dir_path) + .and_then(|rest| rest.strip_prefix('/')) + .is_some() +} + +/// Build a [`DirSummary`] for `path`, filtering `files` (already scoped by the caller to +/// whichever changeset(s) the selected [`crate::outline::OutlineItem::Dir`] row's `cs_idx` +/// covers — see `App::summary_for`) down to the ones under `path`. +pub fn dir_summary(path: String, files: &[FileChange]) -> DirSummary { + let scoped: Vec = files + .iter() + .filter(|f| path_is_under(&f.path, &path)) + .cloned() + .collect(); + let (files, total_adds, total_dels) = file_rows(&scoped); + DirSummary { + path, + files, + total_adds, + total_dels, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{FileStatus, Hunk, HunkLine}; + + fn hunk_line(kind: LineKind) -> HunkLine { + HunkLine { + kind, + content: b"x\n".to_vec(), + old_lnum: None, + new_lnum: None, + missing_newline: false, + } + } + + fn file(path: &str, adds: usize, dels: usize, contexts: usize) -> FileChange { + let mut lines = Vec::new(); + for _ in 0..adds { + lines.push(hunk_line(LineKind::Addition)); + } + for _ in 0..dels { + lines.push(hunk_line(LineKind::Deletion)); + } + for _ in 0..contexts { + lines.push(hunk_line(LineKind::Context)); + } + FileChange { + path: path.to_string(), + old_path: None, + status: FileStatus::Modified, + is_binary: false, + old_mode: 0o100644, + new_mode: 0o100644, + hunks: vec![Hunk { + old_start: 1, + old_count: 1, + new_start: 1, + new_count: 1, + header: Vec::new(), + lines, + }], + } + } + + #[test] + fn file_diffstat_counts_adds_and_dels_but_not_context() { + let f = file("a.rs", 3, 2, 5); + assert_eq!(file_diffstat(&f), (3, 2)); + } + + #[test] + fn file_diffstat_is_zero_for_a_binary_file_with_no_hunks() { + let f = FileChange { + path: "bin.png".to_string(), + old_path: None, + status: FileStatus::Modified, + is_binary: true, + old_mode: 0o100644, + new_mode: 0o100644, + hunks: Vec::new(), + }; + assert_eq!(file_diffstat(&f), (0, 0)); + } + + #[test] + fn changeset_summary_totals_every_files_diffstat() { + let files = vec![file("a.rs", 2, 1, 0), file("b.rs", 0, 3, 0)]; + let summary = changeset_summary( + "My Title".to_string(), + true, + false, + false, + false, + None, + &files, + ); + assert_eq!(summary.label, "My Title"); + assert!(summary.current); + assert!(!summary.needs_restack); + assert_eq!(summary.files.len(), 2); + assert_eq!(summary.total_adds, 2); + assert_eq!(summary.total_dels, 4); + } + + #[test] + fn changeset_summary_loading_carries_no_files() { + let summary = changeset_summary( + "Pending CS".to_string(), + false, + false, + true, + false, + None, + &[], + ); + assert!(summary.loading); + assert!(summary.files.is_empty()); + assert_eq!(summary.total_adds, 0); + } + + #[test] + fn changeset_summary_failed_carries_the_message() { + let summary = changeset_summary( + "Failed CS".to_string(), + false, + false, + false, + true, + Some("boom".to_string()), + &[], + ); + assert!(summary.failed); + assert_eq!(summary.failure_message.as_deref(), Some("boom")); + } + + #[test] + fn dir_summary_filters_by_segment_boundary_not_raw_prefix() { + let files = vec![ + file("src/a.rs", 1, 0, 0), + file("src/b.rs", 0, 1, 0), + file("src2/b.rs", 5, 5, 0), + file("top.rs", 1, 1, 0), + ]; + let summary = dir_summary("src".to_string(), &files); + let paths: Vec<&str> = summary.files.iter().map(|r| r.path.as_str()).collect(); + assert_eq!( + paths, + vec!["src/a.rs", "src/b.rs"], + "must match src/* but NOT src2/* (segment-boundary, not raw string prefix)" + ); + assert_eq!(summary.total_adds, 1); + assert_eq!(summary.total_dels, 1); + } + + #[test] + fn dir_summary_matches_nested_paths_under_the_dir() { + let files = vec![file("src/a/b.rs", 2, 0, 0), file("src/c.rs", 0, 2, 0)]; + let summary = dir_summary("src".to_string(), &files); + assert_eq!(summary.files.len(), 2); + assert_eq!(summary.total_adds, 2); + assert_eq!(summary.total_dels, 2); + } +} From 2de9ce2007300b587753c0ee3f7a27f53354c0d6 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 23:02:38 -0400 Subject: [PATCH 107/203] fix(review): extract shared push_summary_body from summary builders --- git-workon-review/src/render.rs | 89 ++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 40 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index eb43df6..0638d0c 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -824,6 +824,39 @@ fn push_summary_file_rows( ))); } +/// Append the shared summary body — spacer, height-budgeted per-file rows, and the +/// `"{N} files +A -D"` totals line — used verbatim by both [`changeset_summary_lines`] and +/// [`dir_summary_lines`], which differ only in their title line and early-return states. +fn push_summary_body( + lines: &mut Vec>, + files: &[SummaryFileRow], + total_adds: usize, + total_dels: usize, + height: usize, + theme: &Palette, +) { + lines.push(Line::from("")); + let footer_budget = 1; // the totals line always shows + let file_budget = height.saturating_sub(lines.len() + footer_budget); + push_summary_file_rows(lines, files, file_budget, theme); + lines.push(Line::from(vec![ + TSpan::styled( + format!("{} files", files.len()), + Style::default().fg(theme.foreground), + ), + TSpan::raw(" "), + TSpan::styled( + format!("+{total_adds}"), + Style::default().fg(theme.add_strong), + ), + TSpan::raw(" "), + TSpan::styled( + format!("-{total_dels}"), + Style::default().fg(theme.del_strong), + ), + ])); +} + /// Build a [`ChangesetSummary`]'s lines: title line (carrying the same current/needs-restack/ /// failed markers `build_outline_line`'s Header arm draws), a loading/failed line OR the per-file /// list + totals line. @@ -868,26 +901,14 @@ fn changeset_summary_lines( return lines; } - lines.push(Line::from("")); - let footer_budget = 1; // the totals line always shows - let file_budget = height.saturating_sub(lines.len() + footer_budget); - push_summary_file_rows(&mut lines, &summary.files, file_budget, theme); - lines.push(Line::from(vec![ - TSpan::styled( - format!("{} files", summary.files.len()), - Style::default().fg(theme.foreground), - ), - TSpan::raw(" "), - TSpan::styled( - format!("+{}", summary.total_adds), - Style::default().fg(theme.add_strong), - ), - TSpan::raw(" "), - TSpan::styled( - format!("-{}", summary.total_dels), - Style::default().fg(theme.del_strong), - ), - ])); + push_summary_body( + &mut lines, + &summary.files, + summary.total_adds, + summary.total_dels, + height, + theme, + ); lines } @@ -900,26 +921,14 @@ fn dir_summary_lines(summary: &DirSummary, height: usize, theme: &Palette) -> Ve .fg(theme.foreground) .add_modifier(Modifier::BOLD), ))]; - lines.push(Line::from("")); - let footer_budget = 1; - let file_budget = height.saturating_sub(lines.len() + footer_budget); - push_summary_file_rows(&mut lines, &summary.files, file_budget, theme); - lines.push(Line::from(vec![ - TSpan::styled( - format!("{} files", summary.files.len()), - Style::default().fg(theme.foreground), - ), - TSpan::raw(" "), - TSpan::styled( - format!("+{}", summary.total_adds), - Style::default().fg(theme.add_strong), - ), - TSpan::raw(" "), - TSpan::styled( - format!("-{}", summary.total_dels), - Style::default().fg(theme.del_strong), - ), - ])); + push_summary_body( + &mut lines, + &summary.files, + summary.total_adds, + summary.total_dels, + height, + theme, + ); lines } From d0ca649e8baeef12a44d701a3764d3fbf433c2aa Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 23:04:19 -0400 Subject: [PATCH 108/203] fix(review): build summaries from borrows, no FileChange clones --- git-workon-review/src/app.rs | 9 ++++++--- git-workon-review/src/summary.rs | 28 ++++++++++++++++------------ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 9b46d63..d9bc935 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -2210,7 +2210,10 @@ impl App { // the aggregate to that changeset's own files (mirrors `build_stack_tree`'s "no // cross-changeset dedup" rule). let view = &self.changesets[cs_idx]; - Summary::Dir(summary::dir_summary(path, view.files())) + Summary::Dir(summary::dir_summary( + path, + &view.files().iter().collect::>(), + )) } SummaryTarget::Dir { cs_idx: None, path } => { // Tree mode: the dir row's trie spans the whole stack with no single owning @@ -2224,10 +2227,10 @@ impl App { let mut entries: Vec<(&String, &(usize, usize, outline::StagedStatus))> = latest.iter().collect(); entries.sort_by(|a, b| a.0.cmp(b.0)); - let files: Vec = entries + let files: Vec<&FileChange> = entries .into_iter() .filter_map(|(_, &(cs_idx, file_idx, _))| { - self.changesets[cs_idx].files().get(file_idx).cloned() + self.changesets[cs_idx].files().get(file_idx) }) .collect(); Summary::Dir(summary::dir_summary(path, &files)) diff --git a/git-workon-review/src/summary.rs b/git-workon-review/src/summary.rs index 25996bf..8ac9e7f 100644 --- a/git-workon-review/src/summary.rs +++ b/git-workon-review/src/summary.rs @@ -43,10 +43,14 @@ pub struct SummaryFileRow { /// Build the per-file row list plus its `(total_adds, total_dels)` from `files` — shared by /// [`changeset_summary`] and [`dir_summary`], the only difference between the two being which -/// files the caller has already filtered down to. -fn file_rows(files: &[FileChange]) -> (Vec, usize, usize) { +/// files the caller has already filtered down to. Borrows only — a summary reads `path` and the +/// hunk line kinds, so no caller should ever need to clone a `FileChange` (with its full hunk +/// content bytes) just to build one. +fn file_rows<'a>( + files: impl IntoIterator, +) -> (Vec, usize, usize) { let rows: Vec = files - .iter() + .into_iter() .map(|f| { let (adds, dels) = file_diffstat(f); SummaryFileRow { @@ -128,14 +132,14 @@ fn path_is_under(file_path: &str, dir_path: &str) -> bool { /// Build a [`DirSummary`] for `path`, filtering `files` (already scoped by the caller to /// whichever changeset(s) the selected [`crate::outline::OutlineItem::Dir`] row's `cs_idx` -/// covers — see `App::summary_for`) down to the ones under `path`. -pub fn dir_summary(path: String, files: &[FileChange]) -> DirSummary { - let scoped: Vec = files +/// covers — see `App::summary_for`) down to the ones under `path`. Takes refs — see +/// [`file_rows`]'s doc for why no `FileChange` is ever cloned here. +pub fn dir_summary(path: String, files: &[&FileChange]) -> DirSummary { + let scoped = files .iter() - .filter(|f| path_is_under(&f.path, &path)) - .cloned() - .collect(); - let (files, total_adds, total_dels) = file_rows(&scoped); + .copied() + .filter(|f| path_is_under(&f.path, &path)); + let (files, total_adds, total_dels) = file_rows(scoped); DirSummary { path, files, @@ -267,7 +271,7 @@ mod tests { file("src2/b.rs", 5, 5, 0), file("top.rs", 1, 1, 0), ]; - let summary = dir_summary("src".to_string(), &files); + let summary = dir_summary("src".to_string(), &files.iter().collect::>()); let paths: Vec<&str> = summary.files.iter().map(|r| r.path.as_str()).collect(); assert_eq!( paths, @@ -281,7 +285,7 @@ mod tests { #[test] fn dir_summary_matches_nested_paths_under_the_dir() { let files = vec![file("src/a/b.rs", 2, 0, 0), file("src/c.rs", 0, 2, 0)]; - let summary = dir_summary("src".to_string(), &files); + let summary = dir_summary("src".to_string(), &files.iter().collect::>()); assert_eq!(summary.files.len(), 2); assert_eq!(summary.total_adds, 2); assert_eq!(summary.total_dels, 2); From 67c3f3ea791aaa16f692459e06416f069d7e3766 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 19:29:13 -0400 Subject: [PATCH 109/203] feat(review): file status letters and opt-in nerd icons in outline --- git-workon-review/src/app.rs | 114 +++++++++++++++++- git-workon-review/src/config.rs | 23 ++++ git-workon-review/src/icons.rs | 91 ++++++++++++++ git-workon-review/src/lib.rs | 1 + git-workon-review/src/model.rs | 20 ++++ git-workon-review/src/outline.rs | 97 ++++++++++++--- git-workon-review/src/render.rs | 198 ++++++++++++++++++++++++++++++- 7 files changed, 520 insertions(+), 24 deletions(-) create mode 100644 git-workon-review/src/icons.rs diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index d9bc935..56e3ff0 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -20,6 +20,7 @@ use crate::align::{align_file, collapse_gaps, inline_rows, CellKind, DisplayRow, use crate::apply::{Git2Applier, StageVerb}; use crate::config::RawViewConfig; use crate::highlight::{FgSpan, TsHighlighter}; +use crate::icons::OutlineIcons; use crate::model::{DiffModel, FileChange, FileStatus, Hunk, LineKind}; use crate::ops; use crate::outline::{self, OutlineChangeset, OutlineFile, OutlineItem, OutlineMode, OutlineOrder}; @@ -564,6 +565,18 @@ fn parse_outline_order(raw: &str) -> Option { } } +/// Parse `workon.review.outline.icons` (CS5) into an [`OutlineIcons`]. Canonical strings mirror +/// the variant names, kebab-cased: `nerd`, `none`. `None` on anything else — +/// [`App::apply_view_config`] falls back to [`OutlineIcons::default`] (also `none` — CS5's +/// no-auto-detection default) and warns. +fn parse_outline_icons(raw: &str) -> Option { + match raw { + "nerd" => Some(OutlineIcons::Nerd), + "none" => Some(OutlineIcons::None), + _ => None, + } +} + /// 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. @@ -633,6 +646,10 @@ pub struct OutlineState { /// Which end of the stack the stack-shaped modes display first — `workon.review.outline.order` /// (CS3), defaulting to [`OutlineOrder::HeadFirst`]. Read by [`App::outline_items`]. pub order: OutlineOrder, + /// CS5: opt-in nerd-font file/dir icons — `workon.review.outline.icons`, defaulting to + /// [`OutlineIcons::None`] (no auto-detection story exists — a terminal can't report the + /// user's font). Read by `render::build_outline_line`. + pub icons: OutlineIcons, } /// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the @@ -1065,6 +1082,7 @@ impl App { width: DEFAULT_OUTLINE_WIDTH, scroll: 0, order: OutlineOrder::default(), + icons: OutlineIcons::default(), }; let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial @@ -2145,6 +2163,7 @@ impl App { .map(|(idx, f)| OutlineFile { path: f.path.clone(), status: v.staged_status(idx), + change: f.status, }) .collect(), }) @@ -2224,12 +2243,14 @@ impl App { // the same alpha order the Tree outline itself paints (`emit`'s own sort). let snapshot = self.outline_snapshot(); let latest = outline::latest_by_path(&snapshot); - let mut entries: Vec<(&String, &(usize, usize, outline::StagedStatus))> = - latest.iter().collect(); + let mut entries: Vec<( + &String, + &(usize, usize, outline::StagedStatus, FileStatus), + )> = latest.iter().collect(); entries.sort_by(|a, b| a.0.cmp(b.0)); let files: Vec<&FileChange> = entries .into_iter() - .filter_map(|(_, &(cs_idx, file_idx, _))| { + .filter_map(|(_, &(cs_idx, file_idx, _, _))| { self.changesets[cs_idx].files().get(file_idx) }) .collect(); @@ -2273,6 +2294,12 @@ impl App { self.outline.order } + /// CS5: whether the outline renders nerd-font icons — `workon.review.outline.icons`, or + /// [`OutlineIcons::default`] (`None`) if never set. + pub fn outline_icons(&self) -> OutlineIcons { + self.outline.icons + } + /// `o`: a pure show/hide toggle — closed -> open+focused (+[`Self::sync_outline_to_current`]), /// open (regardless of focus) -> closed+diff-focused. Focus itself is now a separate concern /// handled by [`Self::focus_outline`]/[`Self::focus_diff`] (`h`/`l`) — `o` only ever changes @@ -2345,6 +2372,14 @@ impl App { self.outline.order = order; } + /// Set the outline icons setting directly — the config-startup (CS5) counterpart; there is + /// no interactive key for this (icons are a static config choice, not something to toggle + /// mid-session). Same non-resync posture as [`Self::set_outline_mode`]/ + /// [`Self::set_outline_order`]. + pub fn set_outline_icons(&mut self, icons: OutlineIcons) { + self.outline.icons = icons; + } + /// Move the outline's own cursor by `delta` rows (`j`/`k` while the outline has focus), /// clamped into the current row list. Landing on a FILE row jumps the diff there /// immediately (outline -> diff, per the locked design); a HEADER/DIR row itself never @@ -2741,6 +2776,17 @@ impl App { }; self.set_outline_order(order); + let icons = match &raw.outline_icons { + Some(i) => parse_outline_icons(i).unwrap_or_else(|| { + warnings.push(format!( + "workon.review.outline.icons = '{i}' unrecognized; using default" + )); + OutlineIcons::default() + }), + None => OutlineIcons::default(), + }; + self.set_outline_icons(icons); + let layout = match &raw.diff_layout { Some(l) => parse_diff_layout(l).unwrap_or_else(|| { warnings.push(format!( @@ -3620,6 +3666,7 @@ mod tests { }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::ReviewConfig; + use crate::icons::OutlineIcons; use crate::model::FileStatus; use crate::outline::{OutlineItem, OutlineMode, OutlineOrder, StagedStatus}; @@ -7467,6 +7514,7 @@ mod tests { file_idx: 0, path: "c1.txt".to_string(), status: StagedStatus::None, + change: FileStatus::Added, guides: Vec::new(), }, "a committed changeset's file must carry no staged-ness status" @@ -7482,6 +7530,32 @@ mod tests { ); } + /// CS5: `outline_snapshot`'s `change` field is lifted from the owning `FileChange::status`, + /// a wholly separate axis from `status` (staged-ness — see `outline::OutlineFile::change`'s + /// doc comment). `c1.txt` is a new file introduced by the committed changeset's head commit + /// (`Added`); `u1.txt` is an untracked worktree file (`Untracked`) — distinct FileStatus + /// values, confirming this isn't just always defaulting to one variant. + #[test] + fn outline_snapshot_lifts_change_status_from_the_file_model_independent_of_staged_status() { + let mut app = committed_and_uncommitted_stack(); + app.outline.mode = OutlineMode::Stack; + let items = app.outline_items(); + + let change_for = |path: &str| { + items + .iter() + .find_map(|it| match it { + OutlineItem::File { + path: p, change, .. + } if p == path => Some(*change), + _ => None, + }) + .unwrap_or_else(|| panic!("{path}'s file row present")) + }; + assert_eq!(change_for("c1.txt"), FileStatus::Added); + assert_eq!(change_for("u1.txt"), FileStatus::Untracked); + } + #[test] fn outline_move_by_on_a_file_row_jumps_the_diff() { let mut app = two_committed_changesets_two_and_one_files(); @@ -7610,6 +7684,7 @@ mod tests { file_idx: 0, path: "b1.txt".to_string(), status: StagedStatus::None, + change: FileStatus::Added, guides: Vec::new(), }, "the outline cursor must follow the diff's new position" @@ -7637,6 +7712,7 @@ mod tests { file_idx: 0, path: "b1.txt".to_string(), status: StagedStatus::None, + change: FileStatus::Added, guides: vec![true], }, "the outline cursor must follow the diff's new position, landing on b1.txt's row \ @@ -7955,6 +8031,7 @@ mod tests { assert_eq!(app.outline_width(), DEFAULT_OUTLINE_WIDTH); assert_eq!(app.outline_mode(), OutlineMode::default()); assert_eq!(app.outline_order(), OutlineOrder::default()); + assert_eq!(app.outline_icons(), OutlineIcons::default()); assert_eq!(app.layout, Layout::default()); assert_eq!(app.zoom, Zoom::default()); } @@ -8052,6 +8129,37 @@ mod tests { assert!(warnings[0].contains("outline.order")); } + #[test] + fn outline_icons_overrides_default_when_set() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.icons", "nerd") + .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.outline_icons(), OutlineIcons::Nerd); + } + + #[test] + fn outline_icons_invalid_falls_back_to_default_with_warning() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.icons", "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.outline_icons(), OutlineIcons::default()); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("outline.icons")); + } + #[test] fn diff_layout_overrides_default_when_set() { let fixture = FixtureBuilder::new() diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index 6353809..0f8e867 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -32,11 +32,20 @@ //! width = 32 //! mode = tree //! order = base-first ; head-first | base-first (default: head-first) +//! icons = nerd ; nerd | none (default: none) //! //! [workon "review.diff"] //! layout = split //! zoom = combined //! ``` +//! +//! ## `outline.icons` (CS5) +//! +//! Opt-in nerd-font file/dir icons in the outline pane. There is deliberately NO auto-detection +//! — a terminal cannot report whether the user's font is patched with the nerd-font glyphs, so +//! guessing would silently render tofu/mojibake for anyone without one. Default is `none` +//! (today's plain text); set `icons = nerd` explicitly once your terminal font supports it. See +//! [`crate::icons`] for the glyph table. use git2::Repository; @@ -103,6 +112,7 @@ pub struct RawViewConfig { pub outline_width: Option, pub outline_mode: Option, pub outline_order: Option, + pub outline_icons: Option, pub diff_layout: Option, pub diff_zoom: Option, } @@ -210,6 +220,12 @@ impl<'repo> ReviewConfig<'repo> { self.get_view_string(View::Outline, "order") } + /// Get `workon.review.outline.icons`, raw. `None` if unset — callers apply the current + /// default ([`crate::icons::OutlineIcons::None`], CS5: no auto-detection story exists). + pub fn outline_icons(&self) -> Result, git2::Error> { + self.get_view_string(View::Outline, "icons") + } + /// Get `workon.review.diff.layout`, raw. `None` if unset. pub fn diff_layout(&self) -> Result, git2::Error> { self.get_view_string(View::Diff, "layout") @@ -232,6 +248,7 @@ impl<'repo> ReviewConfig<'repo> { outline_width: self.outline_width().ok().flatten(), outline_mode: self.outline_mode().ok().flatten(), outline_order: self.outline_order().ok().flatten(), + outline_icons: self.outline_icons().ok().flatten(), diff_layout: self.diff_layout().ok().flatten(), diff_zoom: self.diff_zoom().ok().flatten(), } @@ -416,6 +433,7 @@ mod tests { .config("workon.review.outline.width", "40") .config("workon.review.outline.mode", "tree") .config("workon.review.outline.order", "base-first") + .config("workon.review.outline.icons", "nerd") .config("workon.review.diff.layout", "split") .config("workon.review.diff.zoom", "staged") .build() @@ -432,6 +450,10 @@ mod tests { config.outline_order().expect("order"), Some("base-first".to_string()) ); + assert_eq!( + config.outline_icons().expect("icons"), + Some("nerd".to_string()) + ); assert_eq!( config.diff_layout().expect("layout"), Some("split".to_string()) @@ -451,6 +473,7 @@ mod tests { assert_eq!(config.outline_width().expect("width"), None); assert_eq!(config.outline_mode().expect("mode"), None); assert_eq!(config.outline_order().expect("order"), None); + assert_eq!(config.outline_icons().expect("icons"), None); assert_eq!(config.diff_layout().expect("layout"), None); assert_eq!(config.diff_zoom().expect("zoom"), None); } diff --git a/git-workon-review/src/icons.rs b/git-workon-review/src/icons.rs new file mode 100644 index 0000000..cd3ba7d --- /dev/null +++ b/git-workon-review/src/icons.rs @@ -0,0 +1,91 @@ +//! CS5's opt-in nerd-font file-type icon table — a pure module, no [`crate::app::App`]/ +//! [`crate::outline`] dependency, mirroring [`crate::summary`]'s pure-module posture. +//! +//! A terminal cannot report which font (patched with the nerd-font private-use glyphs or not) +//! the user has configured, so there is NO auto-detection here or anywhere else in the crate — +//! icons are strictly opt-in via `workon.review.outline.icons = nerd` (see `config.rs`'s schema +//! doc block and `App::apply_view_config`). With the config left at its default (`none`), +//! nothing in this module is ever called from `render.rs`. + +/// Which of the outline's icon strategies is active — `workon.review.outline.icons` +/// (`nerd`/`none`), read once at startup by `App::apply_view_config` (CS5 mirrors CS3's +/// `OutlineOrder` plumbing exactly: `RawViewConfig` field -> `ReviewConfig` getter -> +/// `parse_outline_icons` -> warn-and-fallback in `apply_view_config` -> `OutlineState` field). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OutlineIcons { + /// No icon glyph — today's plain `[glyph][letter] path` row (CS5's unconditional part only). + #[default] + None, + /// A nerd-font private-use glyph per file extension (falling back to + /// [`DEFAULT_ICON`]/[`DIR_ICON`]), inserted before the path/name. + Nerd, +} + +/// The directory-row icon (nerd-font `nf-fa-folder`, U+F07B) — used for every +/// [`crate::outline::OutlineItem::Dir`] row when [`OutlineIcons::Nerd`] is active. +pub const DIR_ICON: char = '\u{f07b}'; // nf-fa-folder + +/// The fallback file icon (nerd-font `nf-fa-file`, U+F15B) for any extension not in +/// [`icon_for_path`]'s table (including extensionless files). +pub const DEFAULT_ICON: char = '\u{f15b}'; // nf-fa-file + +/// Look up the nerd-font glyph for `path`'s extension — small, deliberately-curated table +/// covering the languages this crate's own `highlight.rs` already bundles grammars for +/// (`lang_key_for_ext`), plus a couple of common project files. Every codepoint below is in the +/// nerd-font private-use area (`seti`/`devicons`/`fa` icon sets); unrecognized extensions and +/// extensionless files fall back to [`DEFAULT_ICON`]. +pub fn icon_for_path(path: &str) -> char { + // `Cargo.lock`/other `*.lock` files: match on the file NAME first, since "lock" isn't a + // meaningful extension-based language distinction the way the rest of the table is. + let name = path.rsplit('/').next().unwrap_or(path); + if name.ends_with(".lock") { + return '\u{f023}'; // nf-fa-lock + } + let ext = match name.rsplit_once('.') { + Some((_, ext)) => ext, + None => return DEFAULT_ICON, + }; + match ext { + "rs" => '\u{e7a8}', // seti-rust + "lua" => '\u{e620}', // seti-lua + "js" | "mjs" | "cjs" => '\u{e74e}', // seti-javascript + "jsx" | "tsx" => '\u{e7ba}', // seti-react + "ts" | "mts" | "cts" => '\u{e628}', // seti-typescript + "json" => '\u{e60b}', // seti-json + "toml" => '\u{e6b2}', // seti-config (toml has no dedicated seti glyph) + "md" | "markdown" => '\u{e73e}', // seti-markdown + _ => DEFAULT_ICON, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_extensions_map_to_their_glyphs() { + assert_eq!(icon_for_path("src/main.rs"), '\u{e7a8}'); + assert_eq!(icon_for_path("scripts/init.lua"), '\u{e620}'); + assert_eq!(icon_for_path("index.js"), '\u{e74e}'); + assert_eq!(icon_for_path("app.mjs"), '\u{e74e}'); + assert_eq!(icon_for_path("component.tsx"), '\u{e7ba}'); + assert_eq!(icon_for_path("component.jsx"), '\u{e7ba}'); + assert_eq!(icon_for_path("types.ts"), '\u{e628}'); + assert_eq!(icon_for_path("package.json"), '\u{e60b}'); + assert_eq!(icon_for_path("Cargo.toml"), '\u{e6b2}'); + assert_eq!(icon_for_path("README.md"), '\u{e73e}'); + } + + #[test] + fn lock_files_match_on_name_not_extension() { + assert_eq!(icon_for_path("Cargo.lock"), '\u{f023}'); + assert_eq!(icon_for_path("nested/dir/yarn.lock"), '\u{f023}'); + } + + #[test] + fn unknown_and_extensionless_paths_fall_back_to_the_default_icon() { + assert_eq!(icon_for_path("Makefile"), DEFAULT_ICON); + assert_eq!(icon_for_path("script.sh"), DEFAULT_ICON); + assert_eq!(icon_for_path("noextension"), DEFAULT_ICON); + } +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 985babc..33a0363 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -21,6 +21,7 @@ pub mod config; pub mod error; pub mod file_ops; pub mod highlight; +pub mod icons; pub mod keymap; pub mod model; pub mod ops; diff --git a/git-workon-review/src/model.rs b/git-workon-review/src/model.rs index 701d502..06f6560 100644 --- a/git-workon-review/src/model.rs +++ b/git-workon-review/src/model.rs @@ -101,6 +101,26 @@ pub enum FileStatus { Unmerged, } +impl FileStatus { + /// The single-character letter the outline's file rows render for this status (CS5): + /// `M`/`A`/`D`/`R`/`C`/`?`/`U`, mirroring `git status --short`'s XY letters where they exist + /// (`?` for untracked, `U` for unmerged/conflicted — git's own convention, not this crate's + /// invention). No mapping like this existed elsewhere in the crate before CS5 (checked the + /// winbar/header, which only special-cases `Renamed`/`Copied` for the `old -> new` label, + /// never prints a letter) — this is the canonical one going forward. + pub fn letter(self) -> char { + match self { + FileStatus::Modified => 'M', + FileStatus::Added => 'A', + FileStatus::Deleted => 'D', + FileStatus::Renamed => 'R', + FileStatus::Copied => 'C', + FileStatus::Untracked => '?', + FileStatus::Unmerged => 'U', + } + } +} + impl From for FileStatus { fn from(delta: git2::Delta) -> Self { match delta { diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index a490904..8c84ead 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -3,12 +3,20 @@ //! renders and the outline cursor indexes — no [`crate::app::App`]/[`crate::app::ChangesetView`] //! dependency, mirroring how [`crate::attribute`] stays a pure module consumed by `app`/`render`. //! -//! CS3 shipped two of the four modes ([`OutlineMode::Flat`]/[`OutlineMode::Stack`]); CS4 (this -//! revision) adds the two path-trie modes ([`OutlineMode::Tree`]/[`OutlineMode::StackTree`]) via -//! the private [`TrieNode`] builder below. +//! CS3 shipped two of the four modes ([`OutlineMode::Flat`]/[`OutlineMode::Stack`]); CS4 added +//! the two path-trie modes ([`OutlineMode::Tree`]/[`OutlineMode::StackTree`]) via the private +//! [`TrieNode`] builder below. CS5 adds each file row's [`crate::model::FileStatus`] (the `M`/ +//! `A`/`D`/... change-status letter — see [`OutlineFile::change`]/[`OutlineItem::File::change`]'s +//! doc comments for why that's a wholly separate field from [`StagedStatus`], which tracks +//! index/worktree staged-ness, not the underlying change kind). Pulling in +//! `crate::model::FileStatus` keeps this module's pure-data posture intact: `model.rs` is itself +//! a pure data module (no `App`/`ChangesetView` dependency), so importing its plain enum doesn't +//! reintroduce the `App` coupling this module was factored out to avoid. use std::collections::HashMap; +use crate::model::FileStatus; + /// Which of the outline's row-building strategies is active — cycled by `i` (only while the /// outline pane has focus; see `App::outline_cycle_mode`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -123,7 +131,15 @@ impl StagedStatus { #[derive(Debug, Clone)] pub struct OutlineFile { pub path: String, + /// Index/worktree staged-ness — [`StagedStatus::None`] for a committed changeset's files. + /// NOT the same axis as [`Self::change`]: a file can be `Staged` (this field) while its + /// underlying change is `Deleted` (that one) — they answer different questions ("is it + /// staged" vs. "what kind of change is it") and must stay two distinct fields. pub status: StagedStatus, + /// CS5: the underlying change kind (Modified/Added/Deleted/...), lifted from the owning + /// [`crate::model::FileChange::status`] — drives the outline's `M`/`A`/`D`/`R`/`C`/`?`/`U` + /// letter (`render::build_outline_line`), independent of [`Self::status`] above. + pub change: FileStatus, } /// One changeset's outline-relevant data — a snapshot, not a borrow, so this module never needs @@ -200,6 +216,9 @@ pub enum OutlineItem { file_idx: usize, path: String, status: StagedStatus, + /// CS5: the change kind (Modified/Added/Deleted/...) — see [`OutlineFile::change`]'s doc + /// comment on why this is distinct from `status` above. + change: FileStatus, guides: Vec, }, } @@ -258,6 +277,7 @@ fn build_stack(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec Vec Vec HashMap { +) -> HashMap { let mut latest = HashMap::new(); for (cs_idx, cs) in changesets.iter().enumerate() { for (file_idx, file) in cs.files.iter().enumerate() { - latest.insert(file.path.clone(), (cs_idx, file_idx, file.status)); + latest.insert( + file.path.clone(), + (cs_idx, file_idx, file.status, file.change), + ); } } latest @@ -325,14 +349,22 @@ pub(crate) fn latest_by_path( /// collide a file and a directory at the same path, so a node is never both. #[derive(Debug, Default)] struct TrieNode { - file: Option<(usize, usize, StagedStatus)>, + file: Option<(usize, usize, StagedStatus, FileStatus)>, /// Insertion order is irrelevant — [`emit`] re-sorts children (dirs-before-files, alpha /// within group) every time it flattens a node. children: Vec<(String, TrieNode)>, } impl TrieNode { - fn insert(&mut self, segments: &[&str], cs_idx: usize, file_idx: usize, status: StagedStatus) { + #[allow(clippy::too_many_arguments)] + fn insert( + &mut self, + segments: &[&str], + cs_idx: usize, + file_idx: usize, + status: StagedStatus, + change: FileStatus, + ) { let (head, rest) = segments .split_first() .expect("insert is never called with an empty segment list"); @@ -346,9 +378,9 @@ impl TrieNode { let child = &mut self.children[idx].1; if rest.is_empty() { // Last-write-wins: a later insert of the same full path overwrites the leaf data. - child.file = Some((cs_idx, file_idx, status)); + child.file = Some((cs_idx, file_idx, status, change)); } else { - child.insert(rest, cs_idx, file_idx, status); + child.insert(rest, cs_idx, file_idx, status, change); } } } @@ -384,12 +416,13 @@ fn emit( let mut guides = ancestors_last.to_vec(); guides.push(is_last); match child.file { - Some((cs_idx, file_idx, status)) => { + Some((cs_idx, file_idx, status, change)) => { items.push(OutlineItem::File { cs_idx, file_idx, path: name.clone(), status, + change, guides, }); } @@ -419,9 +452,9 @@ fn emit( fn build_tree(changesets: &[OutlineChangeset]) -> Vec { let latest = latest_by_path(changesets); let mut root = TrieNode::default(); - for (path, (cs_idx, file_idx, status)) in &latest { + for (path, (cs_idx, file_idx, status, change)) in &latest { let segments: Vec<&str> = path.split('/').collect(); - root.insert(&segments, *cs_idx, *file_idx, *status); + root.insert(&segments, *cs_idx, *file_idx, *status, *change); } let mut items = Vec::new(); emit(&root, &[], "", None, &mut items); @@ -447,7 +480,7 @@ fn build_stack_tree(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec let mut root = TrieNode::default(); for (file_idx, file) in cs.files.iter().enumerate() { let segments: Vec<&str> = file.path.split('/').collect(); - root.insert(&segments, cs_idx, file_idx, file.status); + root.insert(&segments, cs_idx, file_idx, file.status, file.change); } emit(&root, &[], "", Some(cs_idx), &mut items); } @@ -458,11 +491,32 @@ fn build_stack_tree(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec mod tests { use super::*; + /// `change` defaults to [`FileStatus::Modified`] for every file — the ordinary case, and + /// irrelevant to the order/dedup/depth semantics these tests exercise. Tests that care about + /// a SPECIFIC change status (dedup target resolution) use [`cs_with_change`] instead. fn cs( label: &str, current: bool, needs_restack: bool, files: &[(&str, StagedStatus)], + ) -> OutlineChangeset { + cs_with_change( + label, + current, + needs_restack, + &files + .iter() + .map(|(p, s)| (*p, *s, FileStatus::Modified)) + .collect::>(), + ) + } + + /// [`cs`] variant that lets a test pin each file's [`FileStatus`] explicitly (CS5). + fn cs_with_change( + label: &str, + current: bool, + needs_restack: bool, + files: &[(&str, StagedStatus, FileStatus)], ) -> OutlineChangeset { OutlineChangeset { label: label.to_string(), @@ -472,9 +526,10 @@ mod tests { failed: false, files: files .iter() - .map(|(p, s)| OutlineFile { + .map(|(p, s, c)| OutlineFile { path: p.to_string(), status: *s, + change: *c, }) .collect(), } @@ -519,6 +574,7 @@ mod tests { file_idx: 0, path: "a1.txt".to_string(), status: StagedStatus::None, + change: FileStatus::Modified, guides: Vec::new(), }, OutlineItem::Header { @@ -534,6 +590,7 @@ mod tests { file_idx: 0, path: "b1.txt".to_string(), status: StagedStatus::None, + change: FileStatus::Modified, guides: Vec::new(), }, ] @@ -612,6 +669,7 @@ mod tests { file_idx: 0, path: "shared.txt".to_string(), status: StagedStatus::Unstaged, + change: FileStatus::Modified, guides: Vec::new(), }, "must point at cs-b (the LATER/newer changeset), not cs-a" @@ -691,6 +749,7 @@ mod tests { file_idx: 0, path: "shared.txt".to_string(), status: StagedStatus::Staged, + change: FileStatus::Modified, guides: Vec::new(), }), "target resolution stays head-wins (cs-b) regardless of display order" @@ -757,6 +816,7 @@ mod tests { file_idx: 1, path: "b.rs".to_string(), status: StagedStatus::None, + change: FileStatus::Modified, guides: vec![false, false, false], }, OutlineItem::File { @@ -764,6 +824,7 @@ mod tests { file_idx: 2, path: "c.rs".to_string(), status: StagedStatus::None, + change: FileStatus::Modified, guides: vec![false, false, true], }, OutlineItem::File { @@ -771,6 +832,7 @@ mod tests { file_idx: 3, path: "d.rs".to_string(), status: StagedStatus::None, + change: FileStatus::Modified, guides: vec![false, true], }, OutlineItem::File { @@ -778,6 +840,7 @@ mod tests { file_idx: 0, path: "top.rs".to_string(), status: StagedStatus::None, + change: FileStatus::Modified, guides: vec![true], }, ], @@ -804,6 +867,7 @@ mod tests { file_idx: 0, path: "shared.txt".to_string(), status: StagedStatus::Staged, + change: FileStatus::Modified, guides: vec![true], }], "the shared path must appear exactly once, pointing at the newer changeset" @@ -839,6 +903,7 @@ mod tests { file_idx: 0, path: "y.txt".to_string(), status: StagedStatus::None, + change: FileStatus::Modified, guides: vec![true, true], }, OutlineItem::Header { @@ -854,6 +919,7 @@ mod tests { file_idx: 0, path: "z.txt".to_string(), status: StagedStatus::Unstaged, + change: FileStatus::Modified, guides: vec![true], }, ], @@ -946,6 +1012,7 @@ mod tests { file_idx: 0, path: "z.txt".to_string(), status: StagedStatus::None, + change: FileStatus::Modified, guides: vec![true], }, "cs-b's own file follows immediately under its head-first header" diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 0638d0c..2383c16 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -19,6 +19,7 @@ use crate::app::{ use crate::attribute::Attribution; use crate::config::View; use crate::highlight::FgSpan; +use crate::icons::OutlineIcons; use crate::keymap::{footer_hint, help_sections, Keymap}; use crate::model::FileStatus; use crate::outline::OutlineItem; @@ -488,6 +489,7 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) let cursor = app.outline_cursor(); let focused = app.outline_focused(); let scroll = app.outline_scroll(); + let icons = app.outline_icons(); let buf = frame.buffer_mut(); for row in 0..area.height { @@ -497,7 +499,7 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) continue; }; let is_cursor = item_idx == cursor; - let line = build_outline_line(item, theme); + let line = build_outline_line(item, theme, icons); let line = if is_cursor && focused { apply_cursor_row(line, area.width, theme) } else if is_cursor { @@ -529,9 +531,26 @@ fn tree_prefix(guides: &[bool]) -> String { s } +/// The [`FileStatus`] change-letter's foreground color (CS5): a create-like status (Added/ +/// Untracked) reuses the theme's `add_strong` tint, a destroy-like status (Deleted) reuses +/// `del_strong`, and everything else (Modified/Renamed/Copied/Unmerged — a change to EXISTING +/// content, not a create/destroy) gets the theme's neutral `foreground`. No new [`Palette`] +/// fields — this is deliberately just a remap of tints CS4's summary rows already use. +fn change_letter_color(change: FileStatus, theme: &Palette) -> Color { + match change { + FileStatus::Added | FileStatus::Untracked => theme.add_strong, + FileStatus::Deleted => theme.del_strong, + FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied | FileStatus::Unmerged => { + theme.foreground + } + } +} + /// Build one outline row's rendered [`Line`] — see [`render_outline`]'s doc comment for the -/// marker rules. -fn build_outline_line(item: &OutlineItem, theme: &Palette) -> Line<'static> { +/// marker rules. `icons` (CS5, `workon.review.outline.icons`) is [`OutlineIcons::None`] by +/// default, which reproduces the pre-CS5 row text exactly (no icon glyph, no extra space); only +/// [`OutlineIcons::Nerd`] inserts an icon before the name/path. +fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) -> Line<'static> { match item { OutlineItem::Header { label, @@ -565,7 +584,11 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette) -> Line<'static> { Line::from(spans) } OutlineItem::Dir { name, guides, .. } => { - let text = format!("{}{name}/", tree_prefix(guides)); + let icon = match icons { + OutlineIcons::Nerd => format!("{} ", crate::icons::DIR_ICON), + OutlineIcons::None => String::new(), + }; + let text = format!("{}{icon}{name}/", tree_prefix(guides)); Line::from(TSpan::styled( text, Style::default() @@ -576,10 +599,12 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette) -> Line<'static> { OutlineItem::File { path, status, + change, guides, .. } => { let glyph = status.glyph(); + let letter = change.letter(); // Empty `guides` (Flat/Stack modes) keeps the original two-space indent; a // non-empty `guides` (Tree/StackTree modes) draws tree connectors instead — see // `OutlineItem`'s doc comment for why emptiness is the mode signal. @@ -588,8 +613,24 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette) -> Line<'static> { } else { tree_prefix(guides) }; - let text = format!("{prefix}{glyph} {path}"); - Line::from(TSpan::styled(text, Style::default().fg(theme.foreground))) + let icon = match icons { + OutlineIcons::Nerd => format!("{} ", crate::icons::icon_for_path(path)), + OutlineIcons::None => String::new(), + }; + Line::from(vec![ + TSpan::styled( + format!("{prefix}{glyph}"), + Style::default().fg(theme.foreground), + ), + TSpan::styled( + letter.to_string(), + Style::default().fg(change_letter_color(*change, theme)), + ), + TSpan::styled( + format!(" {icon}{path}"), + Style::default().fg(theme.foreground), + ), + ]) } } } @@ -2762,6 +2803,151 @@ mod tests { ); } + // ── CS5: file status letter + opt-in nerd-font icons ─────────────────────────── + + #[test] + fn outline_file_row_shows_the_modified_change_letter_in_its_own_color() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.rs", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + // A lone changeset defaults the outline closed — force it open so this render test can + // inspect its rows (same pattern as `outline_tree_mode_renders_directory_rows_with_tree_guides`). + if !app.outline_open() { + app.toggle_outline(); + } + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + // Skip y=0: the full-width winbar also names the file ("[1/1] a.rs"), so an unskipped + // search would match it instead of the outline's own row below it. + let row = content + .iter() + .enumerate() + .skip(1) + .find(|(_, r)| r.contains("a.rs")) + .map(|(i, _)| i) + .expect("a.rs's file row present"); + assert!( + content[row].contains('M'), + "expected the Modified change letter 'M' in a.rs's row, got: {:?}", + content[row] + ); + + let letter_x = content[row].find('M').unwrap() as u16; + assert_eq!( + buf.cell((letter_x, row as u16)).unwrap().style().fg, + Some(Palette::dark().foreground), + "Modified is a change-to-existing-content status, so its letter must carry the \ + theme's neutral foreground, not an add/del tint" + ); + } + + #[test] + fn outline_icons_nerd_renders_the_rust_file_icon_and_the_dir_icon() { + 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("src/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.outline_cycle_mode(); // Stack -> Tree, so `src/` renders as its own Dir row + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); + app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + + // Skip y=0 in both searches: the full-width winbar names the path ("[1/1] src/main.rs"), + // so an unskipped search would match it instead of the outline's own rows below it. + let dir_row = content + .iter() + .skip(1) + .find(|r| r.contains("src/")) + .expect("src/ dir row present"); + assert!( + dir_row.contains(crate::icons::DIR_ICON), + "expected the dir icon before src/, got: {dir_row:?}" + ); + let file_row = content + .iter() + .skip(1) + .find(|r| r.contains("main.rs")) + .expect("main.rs file row present"); + assert!( + file_row.contains(crate::icons::icon_for_path("main.rs")), + "expected the rust file icon before main.rs, got: {file_row:?}" + ); + } + + #[test] + fn outline_icons_none_renders_neither_icon() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = changeset_with_nested_paths(&fixture); + if !app.outline_open() { + app.toggle_outline(); + } + app.outline_cycle_mode(); // Stack -> Tree + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); + assert_eq!( + app.outline_icons(), + crate::icons::OutlineIcons::None, + "sanity: icons default to None" + ); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + + assert!( + !content.iter().any(|r| r.contains(crate::icons::DIR_ICON)), + "icons=none must never render the dir icon, got:\n{}", + content.join("\n") + ); + assert!( + !content + .iter() + .any(|r| r.contains(crate::icons::DEFAULT_ICON)), + "icons=none must never render the default file icon, got:\n{}", + content.join("\n") + ); + } + // ── CS4: summary panel ─────────────────────────────────────────────────────── /// The body area's columns, for a render at [`OUTLINE_TEST_WIDTH`] (outline `0..35`, divider From 24894ff771549caa1f171b8d9b4cc0df1d48780f Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 23:07:17 -0400 Subject: [PATCH 110/203] fix(review): name the outline file target struct FileOccurrence --- git-workon-review/src/app.rs | 9 ++-- git-workon-review/src/outline.rs | 73 +++++++++++++++++++------------- 2 files changed, 47 insertions(+), 35 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 56e3ff0..b754bef 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -2243,15 +2243,12 @@ impl App { // the same alpha order the Tree outline itself paints (`emit`'s own sort). let snapshot = self.outline_snapshot(); let latest = outline::latest_by_path(&snapshot); - let mut entries: Vec<( - &String, - &(usize, usize, outline::StagedStatus, FileStatus), - )> = latest.iter().collect(); + let mut entries: Vec<(&String, &outline::FileOccurrence)> = latest.iter().collect(); entries.sort_by(|a, b| a.0.cmp(b.0)); let files: Vec<&FileChange> = entries .into_iter() - .filter_map(|(_, &(cs_idx, file_idx, _, _))| { - self.changesets[cs_idx].files().get(file_idx) + .filter_map(|(_, occ)| { + self.changesets[occ.cs_idx].files().get(occ.file_idx) }) .collect(); Summary::Dir(summary::dir_summary(path, &files)) diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 8c84ead..e03dbb8 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -308,13 +308,13 @@ fn build_flat(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec Vec HashMap { +pub(crate) fn latest_by_path(changesets: &[OutlineChangeset]) -> HashMap { let mut latest = HashMap::new(); for (cs_idx, cs) in changesets.iter().enumerate() { for (file_idx, file) in cs.files.iter().enumerate() { latest.insert( file.path.clone(), - (cs_idx, file_idx, file.status, file.change), + FileOccurrence { + cs_idx, + file_idx, + status: file.status, + change: file.change, + }, ); } } latest } +/// The file a de-duped path (or a trie leaf) resolves to: its true `(cs_idx, file_idx)` address +/// into `App::changesets` plus the two per-file status axes the outline renders — staged-ness +/// ([`StagedStatus`], CS3) and change kind ([`FileStatus`], CS5). Named because the bare 4-tuple +/// it replaced had to be re-explained (and type-annotated) at every use site. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct FileOccurrence { + pub cs_idx: usize, + pub file_idx: usize, + pub status: StagedStatus, + pub change: FileStatus, +} + /// A node in the path trie the tree modes build. A node with `file.is_some()` is a leaf (a /// changed file at that exact path); otherwise it's a pure directory node. Git paths never /// collide a file and a directory at the same path, so a node is never both. #[derive(Debug, Default)] struct TrieNode { - file: Option<(usize, usize, StagedStatus, FileStatus)>, + file: Option, /// Insertion order is irrelevant — [`emit`] re-sorts children (dirs-before-files, alpha /// within group) every time it flattens a node. children: Vec<(String, TrieNode)>, } impl TrieNode { - #[allow(clippy::too_many_arguments)] - fn insert( - &mut self, - segments: &[&str], - cs_idx: usize, - file_idx: usize, - status: StagedStatus, - change: FileStatus, - ) { + fn insert(&mut self, segments: &[&str], occ: FileOccurrence) { let (head, rest) = segments .split_first() .expect("insert is never called with an empty segment list"); @@ -378,9 +385,9 @@ impl TrieNode { let child = &mut self.children[idx].1; if rest.is_empty() { // Last-write-wins: a later insert of the same full path overwrites the leaf data. - child.file = Some((cs_idx, file_idx, status, change)); + child.file = Some(occ); } else { - child.insert(rest, cs_idx, file_idx, status, change); + child.insert(rest, occ); } } } @@ -416,13 +423,13 @@ fn emit( let mut guides = ancestors_last.to_vec(); guides.push(is_last); match child.file { - Some((cs_idx, file_idx, status, change)) => { + Some(occ) => { items.push(OutlineItem::File { - cs_idx, - file_idx, + cs_idx: occ.cs_idx, + file_idx: occ.file_idx, path: name.clone(), - status, - change, + status: occ.status, + change: occ.change, guides, }); } @@ -452,9 +459,9 @@ fn emit( fn build_tree(changesets: &[OutlineChangeset]) -> Vec { let latest = latest_by_path(changesets); let mut root = TrieNode::default(); - for (path, (cs_idx, file_idx, status, change)) in &latest { + for (path, occ) in &latest { let segments: Vec<&str> = path.split('/').collect(); - root.insert(&segments, *cs_idx, *file_idx, *status, *change); + root.insert(&segments, *occ); } let mut items = Vec::new(); emit(&root, &[], "", None, &mut items); @@ -480,7 +487,15 @@ fn build_stack_tree(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec let mut root = TrieNode::default(); for (file_idx, file) in cs.files.iter().enumerate() { let segments: Vec<&str> = file.path.split('/').collect(); - root.insert(&segments, cs_idx, file_idx, file.status, file.change); + root.insert( + &segments, + FileOccurrence { + cs_idx, + file_idx, + status: file.status, + change: file.change, + }, + ); } emit(&root, &[], "", Some(cs_idx), &mut items); } From c051483c0b0d0806e2790dec72ab834fea183a52 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 19:58:45 -0400 Subject: [PATCH 111/203] feat(review): preserve diff position across staging operations --- git-workon-review/src/app.rs | 424 ++++++++++++++++++++++++++++++++++- 1 file changed, 422 insertions(+), 2 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index b754bef..4da09eb 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -671,6 +671,72 @@ struct PaneState { scroll: usize, } +/// CS6: a staging op's pre-op position, captured by [`App::capture_position`] before +/// `coordinated_refresh` and restored by [`App::restore_position`] after — so a staging op keeps +/// the reviewer's place instead of `reset_panes`' first-hunk reseat (that reseat still runs for +/// every MANUAL nav: file/changeset switches, zoom cycles). `path` + `role` say WHERE (the same +/// file, the pane the reviewer was in); `old_lineno`/`new_lineno` say WHAT (the acted-on row's +/// position in `role`'s own coordinate frame — the two sides a role's rows are diffed against, +/// per [`FileView::load`]'s table). Deliberately NO pre-op zoom snapshot: [`App::restore_position`] +/// re-derives the POST-op [`EffectiveZoom`] from live state, since the op itself is exactly what +/// invalidates a pre-op snapshot. +struct PositionMemento { + path: String, + role: Role, + old_lineno: Option, + new_lineno: Option, +} + +/// The target role's display row (active layout) whose role-native lineno is the first `>= +/// target`, skipping [`DisplayRow::Gap`]/[`InlineRow::Gap`] rows (no lineno to compare). A row's +/// role-native lineno prefers its NEW side, falling back to its OLD side for an unpaired Del (SBS +/// Filler-new) or Add (SBS Filler-old) row — see [`App::restore_position`]'s doc comment for why +/// this same new-preferred rule is correct even across a role change. +/// +/// Falls back to the LAST row carrying any lineno when `target` is past the view's end (staging +/// the acted-on hunk can shrink the file out from under the old lineno). `None` only when the +/// view has no rows with a lineno at all (a to-be-added-content-only file collapsed to nothing — +/// shouldn't happen in practice, but keeps this total). +fn find_nearest_row(view: &FileView, layout: Layout, target: u32) -> Option { + let linenos: Vec<(usize, u32)> = match layout { + Layout::Sbs => view + .display + .iter() + .enumerate() + .filter_map(|(i, row)| { + let DisplayRow::Row(r) = row else { + return None; + }; + let n = match r.new { + Row::Line(n) => Some(n as u32), + Row::Filler => match r.old { + Row::Line(n) => Some(n as u32), + Row::Filler => None, + }, + }; + n.map(|n| (i, n)) + }) + .collect(), + Layout::Inline => view + .inline + .iter() + .enumerate() + .filter_map(|(i, row)| match row { + InlineRow::Context { new, .. } | InlineRow::Add { new, .. } => { + Some((i, *new as u32)) + } + InlineRow::Del { old, .. } => Some((i, *old as u32)), + InlineRow::Gap { .. } => None, + }) + .collect(), + }; + linenos + .iter() + .find(|(_, n)| *n >= target) + .or_else(|| linenos.last()) + .map(|(i, _)| *i) +} + /// Slide `prev_scroll` the minimum amount to keep `cursor` within `[SCROLLOFF, pane_height - 1 - /// SCROLLOFF]` of the viewport, then clamp to `[0, rows - pane_height]` (edge wins over margin). /// The pure core of [`App::derive_scroll`], factored out so a split's unfocused pane can derive its @@ -1659,6 +1725,12 @@ impl App { /// run on file open and zoom change. The two role coordinate spaces disagree, so carrying a /// raw cursor index across a role/zoom switch would be meaningless; jumping to the role's own /// first hunk (the same position a fresh file open lands on) is always valid and predictable. + /// + /// This is also what `coordinated_refresh` leaves behind after a staging op (via + /// `open_current`), since a refresh is itself a file "open" of the post-op state — CS6's + /// `App::restore_position` runs immediately after, overwriting this first-hunk reseat with + /// the reviewer's pre-op position when it can. Every OTHER caller (manual file/changeset + /// nav, zoom cycles) has no such follow-up, so first-hunk-on-open is still what they see. fn reset_panes(&mut self) { // Any file open / zoom change reshapes the coordinate space an active selection is keyed // in, so drop it (see [`Self::selection_anchor`]). @@ -3042,7 +3114,9 @@ impl App { /// Enqueue `op`, drain the queue on the same beat, then act on the outcome: a failure or panic /// surfaces on the footer and skips the refresh (the index is now in whatever partial state /// the failed op left it in — the user resolves with `r`); a `Completed` drain refreshes, - /// rebuilding the views + attribution from the new index (locked decision #5). + /// rebuilding the views + attribution from the new index (locked decision #5), then restores + /// the reviewer's pre-op position (CS6) — a staging op is the ONE nav path that does not reset + /// to the role's first hunk; every manual nav still does, via `reset_panes` unchanged. /// /// Generic over any [`StagingOp`] — a hunk/file op ([`FileStagingOp`]) or a (possibly /// multi-hunk) line selection ([`LineSelectionOp`], which applies as ONE merged patch rather @@ -3050,6 +3124,7 @@ impl App { /// way exactly one op is ever in flight, so the queue's trap-4 live-index staleness doesn't /// apply — the queue is here for its lock-retry and panic isolation. fn run_op(&mut self, op: impl StagingOp + 'static) { + let memento = self.capture_position(); self.queue.enqueue(op); // Distinct fields (`queue` mutable, `repo`/`applier` shared) — the borrow checker permits // the disjoint borrows in one call, so the queue needn't be taken out and put back. @@ -3061,10 +3136,107 @@ impl App { }); match failure { Some(message) => self.notify(message, Severity::Error), - None => self.coordinated_refresh(), + None => { + self.coordinated_refresh(); + if let Some(memento) = memento { + self.restore_position(memento); + } + } } } + /// Snapshot the focused pane's file/role/position ahead of a staging op, for + /// [`Self::restore_position`] to reseat after the op's `coordinated_refresh` (CS6). `None` + /// when there's no current file, the current view is the combined role (never a staging + /// target — [`Self::staging_role`]), or the focused role's view isn't loaded; restore is then + /// a no-op and today's `reset_panes` first-hunk behavior stands. + fn capture_position(&self) -> Option { + let path = self.files().get(self.current)?.path.clone(); + let role = self.staging_role()?; + let view = self.role_view_ref(self.current, role)?; + let (old_lineno, new_lineno) = match self.layout { + Layout::Sbs => match view.display.get(self.cursor) { + Some(DisplayRow::Row(row)) => ( + match row.old { + Row::Line(n) => Some(n as u32), + Row::Filler => None, + }, + match row.new { + Row::Line(n) => Some(n as u32), + Row::Filler => None, + }, + ), + _ => (None, None), + }, + Layout::Inline => match view.inline.get(self.cursor) { + Some(InlineRow::Context { old, new }) => (Some(*old as u32), Some(*new as u32)), + Some(InlineRow::Del { old, .. }) => (Some(*old as u32), None), + Some(InlineRow::Add { new, .. }) => (None, Some(*new as u32)), + _ => (None, None), + }, + }; + Some(PositionMemento { + path, + role, + old_lineno, + new_lineno, + }) + } + + /// Reseat the focused pane to a pre-staging-op position after `coordinated_refresh` rebuilds + /// the views (CS6) — the staging-path counterpart to `reset_panes`' first-hunk reseat, which + /// this deliberately leaves untouched for every manual nav (file/changeset switch, zoom + /// cycle). Falls back to whatever `reset_panes` already produced (today's first-hunk + /// behavior) when the acted-on file's path is gone (fully discarded) or its memento carried + /// no lineno at all (the cursor sat on a `Gap` row pre-op — nothing to search for). + fn restore_position(&mut self, m: PositionMemento) { + if self.files().get(self.current).map(|f| f.path.as_str()) != Some(m.path.as_str()) { + return; + } + // Force the load `reset_panes` may have deferred so the view below actually exists. + self.complete_pending_open(); + + // Target role: a still-`Split` file keeps both panes, so stay on the memento's own role + // (locked decision: same file, same pane, unless that pane's role is now gone). A + // collapsed-to-`Single` file has exactly one surviving role — THAT is the target + // regardless of which pane the op started in, which is what lands "fully staging a file + // in Split" in the staged pane of the same file. + let target_role = match self.effective_zoom_for(self.current) { + EffectiveZoom::Split => m.role, + EffectiveZoom::Single(role) => role, + }; + if matches!(self.effective_zoom_for(self.current), EffectiveZoom::Split) + && self.split_focus_role() != target_role + { + // Never assign `split_focus` directly — this swaps the cursor/scroll/pane-height + // stashes along with it. + self.toggle_split_focus(); + } + + let Some(view) = self.role_view_ref(self.current, target_role) else { + return; + }; + + // The memento's linenos were captured in `m.role`'s own frame (new = worktree for + // Unstaged/Combined, new = index for Staged — see `FileView::load`'s table). Preferring + // new over old is correct BOTH when the role is unchanged (the common case: same pane, + // same frame) AND on the one role change that can happen here — unstaged -> staged after + // fully staging a file in Split. In that case the staged view's new side (index) now + // holds exactly what the unstaged view's new side (worktree) held a moment ago, because + // staging made index == worktree for this file; so new -> new is still the right + // mapping, and `find_nearest_row` falls back to a row's old side only for the rows that + // never had a new side to begin with (an unpaired Del). + let Some(target_lineno) = m.new_lineno.or(m.old_lineno) else { + return; + }; + let Some(cursor) = find_nearest_row(view, self.layout, target_lineno) else { + return; + }; + self.cursor = cursor; + self.clamp_cursor(); + self.derive_scroll(); + } + /// Start a line selection anchored at the current cursor (`v`). Refuses (a notice, no anchor /// set) on the combined view or any non-staging role — you can only select lines where you can /// stage them (same gate as the verbs). A no-op on an empty file list. @@ -5877,6 +6049,254 @@ mod tests { repo.assert(predicate::repo::has_untracked_file("new.txt")); } + // ---- CS6: staging preserves diff position ---------------------------------------------- + + /// Three single-line edits well-separated (>6 lines of pure context apart, git's own + /// hunk-splitting threshold) so each is its own hunk AND the context between any two + /// collapses to a [`DisplayRow::Gap`] — exercising both the mid-file-hunk and the + /// lands-in-a-gap restore paths. + fn three_hunk_fixture() -> Fixture { + let head: String = (1..=24).map(|n| format!("L{n}\n")).collect(); + let worktree: String = (1..=24) + .map(|n| { + if n == 2 || n == 12 || n == 22 { + format!("L{n}X\n") + } else { + format!("L{n}\n") + } + }) + .collect(); + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", &head, &worktree) + .build() + .unwrap() + } + + /// The row-native lineno `App::restore_position` would target for `row` — new side, + /// falling back to old — used by these tests to check where the cursor actually landed + /// without re-deriving the production search itself. + fn row_lineno(row: &DisplayRow) -> Option { + match row { + DisplayRow::Row(r) => match r.new { + Row::Line(n) => Some(n), + Row::Filler => match r.old { + Row::Line(n) => Some(n), + Row::Filler => None, + }, + }, + DisplayRow::Gap { .. } => None, + } + } + + #[test] + fn stage_hunk_on_a_middle_hunk_lands_the_cursor_near_it_not_at_the_first_hunk() { + let fixture = three_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); // Single(Unstaged): no staged half exists yet. + let first_hunk_row = app.cursor; + + app.next_hunk_row(); // hunk 1 (line 2) -> hunk 2 (line 12) + let hunk2_row = app.cursor; + assert_ne!( + hunk2_row, first_hunk_row, + "test setup: must have moved off hunk 1" + ); + + app.stage_hunk(); // stages ONLY hunk 2 -> the file now has both sub-diffs again + + assert!(app.notice.is_none(), "stage must succeed: {:?}", app.notice); + assert_eq!( + app.effective_zoom_for(app.current), + EffectiveZoom::Split, + "hunks 1/3 stayed unstaged, hunk 2 is now staged — both halves exist" + ); + assert_eq!( + app.split_focus_role(), + Role::Unstaged, + "the memento's own role (Unstaged) survives, so it stays the target" + ); + assert_ne!( + app.cursor, first_hunk_row, + "must NOT reset to the first hunk (today's manual-nav-only behavior)" + ); + + let view = app.role_view_ref(app.current, Role::Unstaged).unwrap(); + let lineno = row_lineno(&view.display[app.cursor]) + .expect("restore must not land the cursor back on a Gap row"); + assert!( + lineno > 2 && lineno < 22, + "expected the cursor between hunk 1 (line 2) and hunk 3 (line 22) — near hunk 2's \ + old position (line 12) — got line {lineno}" + ); + } + + #[test] + fn fully_staging_a_file_in_split_lands_the_cursor_in_the_staged_pane_at_the_same_lines() { + let fixture = partial_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); // Split; focused pane defaults to Unstaged, on gamma's hunk (line 3) + assert_eq!(app.effective_zoom_for(app.current), EffectiveZoom::Split); + assert_eq!(app.split_focus_role(), Role::Unstaged); + + app.stage_hunk(); // stages the only unstaged hunk -> the file is now fully staged + + assert!(app.notice.is_none(), "stage must succeed: {:?}", app.notice); + assert_eq!( + app.effective_zoom_for(app.current), + EffectiveZoom::Single(Role::Staged), + "no unstaged half survives a full stage" + ); + + let view = app.role_view_ref(app.current, Role::Staged).unwrap(); + let staged_first_hunk_row = match app.layout { + Layout::Sbs => view.first_hunk_row, + Layout::Inline => view.first_inline_hunk_row, + }; + assert_ne!( + app.cursor, staged_first_hunk_row, + "must land on gamma's own row, not beta's (the staged view's first hunk)" + ); + let lineno = row_lineno(&view.display[app.cursor]).expect("gamma's row has a lineno"); + assert_eq!( + lineno, 3, + "gamma is line 3 in both HEAD and the fully-staged index" + ); + } + + #[test] + fn unstaging_in_the_staged_pane_keeps_focus_there_when_it_survives() { + let head: String = (1..=14).map(|n| format!("L{n}\n")).collect(); + // Index stages two well-separated edits (lines 2 and 10); the worktree matches the + // index except for one MORE edit (line 14) that was never staged. + let index: String = (1..=14) + .map(|n| { + if n == 2 || n == 10 { + format!("L{n}X\n") + } else { + format!("L{n}\n") + } + }) + .collect(); + let worktree: String = (1..=14) + .map(|n| { + if n == 2 || n == 10 { + format!("L{n}X\n") + } else if n == 14 { + format!("L{n}X\n") + } else { + format!("L{n}\n") + } + }) + .collect(); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .partially_staged_file("f.txt", &head, &index, &worktree) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); // Split; focused pane defaults to Unstaged (line 14's hunk) + app.toggle_split_focus(); // -> Staged pane, cursor on hunk 1 (line 2) + let first_hunk_row = app.cursor; + app.next_hunk_row(); // -> hunk 2 (line 10) + assert_ne!( + app.cursor, first_hunk_row, + "test setup: must have moved off hunk 1" + ); + + app.stage_hunk(); // staged pane -> unstage direction: reverts line 10's index entry + + assert!( + app.notice.is_none(), + "unstage must succeed: {:?}", + app.notice + ); + assert_eq!( + app.effective_zoom_for(app.current), + EffectiveZoom::Split, + "line 2 stays staged and line 10/14 are both unstaged now — both halves survive" + ); + assert_eq!( + app.split_focus_role(), + Role::Staged, + "the memento's own role (Staged) survives, so focus stays there" + ); + + let view = app.role_view_ref(app.current, Role::Staged).unwrap(); + let staged_first_hunk_row = match app.layout { + Layout::Sbs => view.first_hunk_row, + Layout::Inline => view.first_inline_hunk_row, + }; + assert_ne!( + app.cursor, staged_first_hunk_row, + "must NOT reset to the (now sole) first hunk at line 2" + ); + } + + #[test] + fn discarding_the_only_file_in_the_changeset_falls_back_gracefully_without_panicking() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("only.txt", "hello\nworld\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert_eq!(app.files().len(), 1); + + app.discard_file(); + assert!(app.pending_confirm.is_some()); + app.resolve_confirm(true); // runs the discard through run_op -> restore_position + + assert!( + app.notice.is_none(), + "discard must succeed: {:?}", + app.notice + ); + assert!( + app.files().is_empty(), + "the untracked file's only diff vanishes once discarded" + ); + assert_eq!( + app.cursor, 0, + "the path check bails out; reset_panes' fallback stands" + ); + } + + #[test] + fn staging_with_the_cursor_on_a_gap_row_falls_back_without_panicking() { + let fixture = three_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + let gap_row = { + let view = app.role_view_ref(app.current, Role::Unstaged).unwrap(); + view.display + .iter() + .position(|r| matches!(r, DisplayRow::Gap { .. })) + .expect("three well-separated hunks must collapse a gap between them") + }; + app.cursor = gap_row; + + app.stage_file(); // whole-file op: ignores the cursor for WHAT it stages + + assert!(app.notice.is_none(), "stage must succeed: {:?}", app.notice); + assert_eq!( + app.effective_zoom_for(app.current), + EffectiveZoom::Single(Role::Staged), + "no unstaged half survives a whole-file stage" + ); + // The pre-op cursor sat on a Gap row, so the memento carried no lineno — restore is a + // no-op and today's `reset_panes` first-hunk reseat stands. + let view = app.role_view_ref(app.current, Role::Staged).unwrap(); + let expected = match app.layout { + Layout::Sbs => view.first_hunk_row, + Layout::Inline => view.first_inline_hunk_row, + }; + assert_eq!(app.cursor, expected); + } + // ---- M4 staging: discard confirm flow -------------------------------------------------- #[test] From 4b741464819003d5fb1dfce508da66a070fb2602 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 23:11:34 -0400 Subject: [PATCH 112/203] fix(review): search cursor restore in a single lineno frame --- git-workon-review/src/app.rs | 100 ++++++++++++++++------------------- 1 file changed, 46 insertions(+), 54 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 4da09eb..10e97a5 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -683,50 +683,46 @@ struct PaneState { struct PositionMemento { path: String, role: Role, - old_lineno: Option, - new_lineno: Option, + old_lineno: Option, + new_lineno: Option, } -/// The target role's display row (active layout) whose role-native lineno is the first `>= -/// target`, skipping [`DisplayRow::Gap`]/[`InlineRow::Gap`] rows (no lineno to compare). A row's -/// role-native lineno prefers its NEW side, falling back to its OLD side for an unpaired Del (SBS -/// Filler-new) or Add (SBS Filler-old) row — see [`App::restore_position`]'s doc comment for why -/// this same new-preferred rule is correct even across a role change. +/// The target role's display row (active layout) whose lineno IN `new_frame`'s coordinate frame +/// (`true` = new side, `false` = old side — the frame the memento's target lineno was captured +/// in) is the first `>= target`. Rows with no lineno on that side — gaps, and the unpaired +/// Del/Add rows whose only lineno lives on the OTHER side — are skipped rather than compared: +/// old-side and new-side numbering diverge as soon as a file has any insertion or deletion above +/// the row, so mixing frames in one monotonic scan would let e.g. a deletion hunk's old-side +/// numbers (which run ahead of the surrounding new-side numbers) capture the cursor first. /// -/// Falls back to the LAST row carrying any lineno when `target` is past the view's end (staging -/// the acted-on hunk can shrink the file out from under the old lineno). `None` only when the -/// view has no rows with a lineno at all (a to-be-added-content-only file collapsed to nothing — -/// shouldn't happen in practice, but keeps this total). -fn find_nearest_row(view: &FileView, layout: Layout, target: u32) -> Option { - let linenos: Vec<(usize, u32)> = match layout { +/// Falls back to the LAST row carrying a lineno in that frame when `target` is past the view's +/// end (staging the acted-on hunk can shrink the file out from under the old lineno). `None` +/// only when NO row carries a lineno in that frame (e.g. anchoring old-frame in an added-only +/// file) — the caller keeps `reset_panes`' first-hunk position then. +fn find_nearest_row( + view: &FileView, + layout: Layout, + target: usize, + new_frame: bool, +) -> Option { + let in_frame = |old: Option, new: Option| if new_frame { new } else { old }; + let linenos: Vec<(usize, usize)> = match layout { Layout::Sbs => view .display .iter() .enumerate() .filter_map(|(i, row)| { - let DisplayRow::Row(r) = row else { - return None; - }; - let n = match r.new { - Row::Line(n) => Some(n as u32), - Row::Filler => match r.old { - Row::Line(n) => Some(n as u32), - Row::Filler => None, - }, - }; - n.map(|n| (i, n)) + let (old, new) = display_row_linenos(row); + in_frame(old, new).map(|n| (i, n)) }) .collect(), Layout::Inline => view .inline .iter() .enumerate() - .filter_map(|(i, row)| match row { - InlineRow::Context { new, .. } | InlineRow::Add { new, .. } => { - Some((i, *new as u32)) - } - InlineRow::Del { old, .. } => Some((i, *old as u32)), - InlineRow::Gap { .. } => None, + .filter_map(|(i, row)| { + let (old, new) = inline_row_linenos(row); + in_frame(old, new).map(|n| (i, n)) }) .collect(), }; @@ -3154,26 +3150,19 @@ impl App { let path = self.files().get(self.current)?.path.clone(); let role = self.staging_role()?; let view = self.role_view_ref(self.current, role)?; + // Reuse the same row -> lineno extraction `FileView::load` builds its hunk maps from + // (a Gap row yields (None, None), which restore treats as nothing-to-search-for). let (old_lineno, new_lineno) = match self.layout { - Layout::Sbs => match view.display.get(self.cursor) { - Some(DisplayRow::Row(row)) => ( - match row.old { - Row::Line(n) => Some(n as u32), - Row::Filler => None, - }, - match row.new { - Row::Line(n) => Some(n as u32), - Row::Filler => None, - }, - ), - _ => (None, None), - }, - Layout::Inline => match view.inline.get(self.cursor) { - Some(InlineRow::Context { old, new }) => (Some(*old as u32), Some(*new as u32)), - Some(InlineRow::Del { old, .. }) => (Some(*old as u32), None), - Some(InlineRow::Add { new, .. }) => (None, Some(*new as u32)), - _ => (None, None), - }, + Layout::Sbs => view + .display + .get(self.cursor) + .map(display_row_linenos) + .unwrap_or((None, None)), + Layout::Inline => view + .inline + .get(self.cursor) + .map(inline_row_linenos) + .unwrap_or((None, None)), }; Some(PositionMemento { path, @@ -3224,12 +3213,15 @@ impl App { // fully staging a file in Split. In that case the staged view's new side (index) now // holds exactly what the unstaged view's new side (worktree) held a moment ago, because // staging made index == worktree for this file; so new -> new is still the right - // mapping, and `find_nearest_row` falls back to a row's old side only for the rows that - // never had a new side to begin with (an unpaired Del). - let Some(target_lineno) = m.new_lineno.or(m.old_lineno) else { - return; + // mapping. Whichever side supplies the target, the SEARCH stays in that same frame — + // `find_nearest_row` never falls back across sides (see its doc for why mixing frames + // mis-lands the cursor). + let (target_lineno, new_frame) = match (m.new_lineno, m.old_lineno) { + (Some(n), _) => (n, true), + (None, Some(o)) => (o, false), + (None, None) => return, }; - let Some(cursor) = find_nearest_row(view, self.layout, target_lineno) else { + let Some(cursor) = find_nearest_row(view, self.layout, target_lineno, new_frame) else { return; }; self.cursor = cursor; From 205bc93de99cf47c1e63e5c9300e2dc811caef65 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 20:25:40 -0400 Subject: [PATCH 113/203] feat(review): stage, unstage, and discard from outline rows --- git-workon-review/src/app.rs | 648 ++++++++++++++++++++++++++++++- git-workon-review/src/keymap.rs | 16 + git-workon-review/src/queue.rs | 10 + git-workon-review/src/summary.rs | 6 +- git-workon-review/src/tui.rs | 6 + 5 files changed, 673 insertions(+), 13 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 10e97a5..c7ced42 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -625,6 +625,51 @@ pub enum Summary { Dir(summary::DirSummary), } +/// CS7: a stable identity for an outline File/Dir row, captured BEFORE a staging/discard op's +/// `coordinated_refresh` rebuilds [`App::outline_items`]'s row list, so the row can be re-found +/// (or gracefully lost, e.g. a fully-discarded file) afterward — see +/// [`App::restore_outline_position`]. `cs_idx`/`path` mirror the row's own fields, EXCEPT a +/// [`OutlineItem::File`]'s `path` here is always the FULL path (from the underlying +/// [`FileChange`]), never the Tree/StackTree leaf-only segment the row itself may display — two +/// rows in different directories can share a leaf name, so the leaf alone isn't a stable key. +/// [`OutlineItem::Dir`]'s own `path` field is already full regardless of mode, so it's reused +/// as-is. No [`OutlineItem::Header`] variant: a header row is never a staging/discard target (see +/// [`App::outline_row_targets`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutlineRowIdentity { + File { cs_idx: usize, path: String }, + Dir { cs_idx: Option, path: String }, +} + +impl OutlineRowIdentity { + /// Whether outline row `item` is the same row this identity was captured from. A + /// [`OutlineItem::File`]'s displayed `path` may be leaf-only (Tree/StackTree) — that's + /// resolved through [`App::outline_row_targets`]'s `(cs_idx, file_idx)` lookup instead of + /// comparing against the row's own `path` field. + fn matches_file(&self, item_cs_idx: usize, full_path: &str) -> bool { + matches!( + self, + OutlineRowIdentity::File { cs_idx, path } + if *cs_idx == item_cs_idx && path == full_path + ) + } + + /// Whether outline row `item` is the same row this identity was captured from. + fn matches_dir(&self, item: &OutlineItem) -> bool { + match (self, item) { + ( + OutlineRowIdentity::Dir { cs_idx, path }, + OutlineItem::Dir { + cs_idx: item_cs_idx, + path: item_path, + .. + }, + ) => cs_idx == item_cs_idx && path == item_path, + _ => false, + } + } +} + /// The outline side pane's own state (locked fork 3): whether it's showing, whether IT (rather /// than the diff) currently has keyboard focus, its own cursor (an index into /// [`App::outline_items`]'s row list — a wholly separate coordinate space from [`App::cursor`]), @@ -1067,6 +1112,18 @@ pub enum PendingOp { file_idx: usize, selections: Vec<(usize, LineSelection)>, }, + /// CS7: discard every `(cs_idx, file_idx)` in `targets` from the worktree — an outline File + /// row's single target, or a Dir row's every file under its path. `identity` is the acted-on + /// outline row's [`OutlineRowIdentity`], captured at request-time (before the confirm modal), + /// so [`App::resolve_confirm`] can hand it to [`App::outline_run_ops`] for the post-op outline + /// cursor restore — by the time `y`/`n` answers the modal, the outline cursor may not still be + /// resting on the row that requested the discard (nothing else moves it in between today, but + /// baking the identity in here rather than re-reading `self.outline.cursor` avoids relying on + /// that). + DiscardOutlineFiles { + targets: Vec<(usize, usize)>, + identity: OutlineRowIdentity, + }, } /// A pending destructive op plus the scope-stating prompt shown on the footer until answered. @@ -2556,6 +2613,224 @@ impl App { self.outline.focused = false; } + // ── Outline staging (CS7) ─────────────────────────────────────────────────── + + /// Whether the changeset at `cs_idx` is a committed range rather than the uncommitted + /// worktree layer — the per-index counterpart to [`Self::is_committed`] (which only reads the + /// ACTIVE changeset). CS7's outline verbs need this because the acted-on row's changeset is + /// whichever one the outline cursor rests on, not necessarily the diff's current changeset. + fn is_committed_at(&self, cs_idx: usize) -> bool { + self.changesets.get(cs_idx).is_some_and(|view| { + matches!( + view.cs.span, + ChangesetSpan::Committed { .. } | ChangesetSpan::CommittedRoot { .. } + ) + }) + } + + /// Resolve the outline row at `idx` to its [`OutlineRowIdentity`] plus the `(cs_idx, + /// file_idx)` pairs an outline stage/discard verb applies to — `None` for a + /// [`OutlineItem::Header`] row (never a staging target) or an out-of-range `idx`. + /// + /// A [`OutlineItem::File`] row resolves to its own single target. A [`OutlineItem::Dir`] row + /// resolves to every file under its `path` (segment-boundary match, [`summary::path_is_under`] + /// — the same rule the summary panel's [`summary::dir_summary`] uses): scoped to that row's own + /// changeset in [`OutlineMode::StackTree`] (`cs_idx: Some`), or to the cross-stack + /// last-write-wins de-duped set [`outline::latest_by_path`] returns in [`OutlineMode::Tree`] + /// (`cs_idx: None`) — mirrors [`Self::summary_for`]'s own Dir-row branching. + fn outline_row_targets(&self, idx: usize) -> Option<(OutlineRowIdentity, Vec<(usize, usize)>)> { + let items = self.outline_items(); + match items.get(idx)? { + OutlineItem::Header { .. } => None, + OutlineItem::File { + cs_idx, file_idx, .. + } => { + let path = self + .changesets + .get(*cs_idx)? + .files() + .get(*file_idx)? + .path + .clone(); + Some(( + OutlineRowIdentity::File { + cs_idx: *cs_idx, + path, + }, + vec![(*cs_idx, *file_idx)], + )) + } + OutlineItem::Dir { path, cs_idx, .. } => { + let identity = OutlineRowIdentity::Dir { + cs_idx: *cs_idx, + path: path.clone(), + }; + let targets = match cs_idx { + Some(cs_idx) => self + .changesets + .get(*cs_idx)? + .files() + .iter() + .enumerate() + .filter(|(_, f)| summary::path_is_under(&f.path, path)) + .map(|(file_idx, _)| (*cs_idx, file_idx)) + .collect(), + None => { + let snapshot = self.outline_snapshot(); + let latest = outline::latest_by_path(&snapshot); + latest + .iter() + .filter(|(p, _)| summary::path_is_under(p, path)) + .map(|(_, &(cs_idx, file_idx, _, _))| (cs_idx, file_idx)) + .collect() + } + }; + Some((identity, targets)) + } + } + } + + /// Per-file verb selection by [`outline::StagedStatus`] — mirrors [`Self::verb_for_role`]'s + /// toggle direction (unstaged stages, staged unstages), but keyed off the FILE's own status + /// rather than a pane role, since a Dir row's files can each carry a different status. + /// [`outline::StagedStatus::None`] shouldn't normally occur on the uncommitted changeset's own + /// file (a changed file always has SOME status) — treated as a Stage attempt so the op surfaces + /// whatever git reports rather than silently refusing. + fn outline_target_verb(&self, cs_idx: usize, file_idx: usize) -> StageVerb { + match self.changesets[cs_idx].staged_status(file_idx) { + outline::StagedStatus::Staged => StageVerb::Unstage, + outline::StagedStatus::Unstaged + | outline::StagedStatus::Partial + | outline::StagedStatus::None => StageVerb::Stage, + } + } + + /// Footer refusal for an outline stage/discard verb — parallels [`Self::notify_combined_refusal`] + /// but for the two CS7-specific refusal reasons: `committed` (the row's changeset — or, for a + /// Dir row, at least one file under it — is a committed range, not the uncommitted worktree + /// layer) or not (the cursor sits on a [`OutlineItem::Header`] row, which is never a target). + fn notify_outline_refusal(&mut self, verb: &str, committed: bool) { + if committed { + self.notify( + format!("changeset is already committed — nothing to {verb}"), + Severity::Error, + ); + } else { + self.notify( + format!("select a file or directory to {verb}"), + Severity::Error, + ); + } + } + + /// `s` while the outline has focus: stage or unstage the file/directory under the cursor. A + /// [`OutlineItem::File`] row stages or unstages per its own [`Self::outline_target_verb`]; a + /// [`OutlineItem::Dir`] row applies the same per-file verb selection to every file under it + /// (each file stages or unstages independently — a mixed-status directory is not an all-stage + /// or all-unstage op). Refuses on a [`OutlineItem::Header`] row or when any target belongs to + /// a committed changeset (see [`Self::notify_outline_refusal`]). + pub fn outline_stage(&mut self) { + let idx = self.outline.cursor; + let Some((identity, targets)) = self.outline_row_targets(idx) else { + self.notify_outline_refusal("stage", false); + return; + }; + if targets + .iter() + .any(|&(cs_idx, _)| self.is_committed_at(cs_idx)) + { + self.notify_outline_refusal("stage", true); + return; + } + if targets.is_empty() { + return; + } + let ops: Vec> = targets + .iter() + .filter_map(|&(cs_idx, file_idx)| { + let file = self.changesets.get(cs_idx)?.files().get(file_idx)?.clone(); + let verb = self.outline_target_verb(cs_idx, file_idx); + Some(Box::new(FileStagingOp::file(file, verb)) as Box) + }) + .collect(); + self.outline_run_ops(ops, identity); + } + + /// `d` while the outline has focus: request confirmation to discard the file/directory under + /// the cursor from the worktree — a [`OutlineItem::File`] row discards just that file; a + /// [`OutlineItem::Dir`] row discards every file under it, and the confirm prompt names the + /// scope. Same refusal gates as [`Self::outline_stage`]. The discard itself runs when the user + /// answers `y` (see [`Self::resolve_confirm`]'s [`PendingOp::DiscardOutlineFiles`] arm). + pub fn outline_discard(&mut self) { + let idx = self.outline.cursor; + let Some((identity, targets)) = self.outline_row_targets(idx) else { + self.notify_outline_refusal("discard", false); + return; + }; + if targets + .iter() + .any(|&(cs_idx, _)| self.is_committed_at(cs_idx)) + { + self.notify_outline_refusal("discard", true); + return; + } + if targets.is_empty() { + return; + } + let prompt = match &identity { + OutlineRowIdentity::File { path, .. } => { + format!("Discard all changes to `{path}`? (y/n)") + } + OutlineRowIdentity::Dir { path, .. } => format!( + "Discard changes to {} files under {path}/? (y/n)", + targets.len() + ), + }; + self.request_confirm(prompt, PendingOp::DiscardOutlineFiles { targets, identity }); + } + + /// The outline-facing counterpart to [`Self::run_op`]: drain `ops` through [`Self::run_ops`], + /// then — on success — restore the OUTLINE cursor to (or nearest to) `identity`'s row rather + /// than a diff-pane position (CS6's [`PositionMemento`]/[`Self::restore_position`] only make + /// sense when the diff pane, not the outline, was the focused surface the op started from). + /// [`Self::coordinated_refresh`] (inside `run_ops`) itself calls `sync_outline_to_current`, + /// which can leave the outline cursor on a wholly unrelated row (wherever the DIFF's current + /// file happens to be) — this runs after that and overwrites it with the acted-on row's own + /// position, or the nearest surviving row if it's gone (e.g. a fully-discarded file). + fn outline_run_ops(&mut self, ops: Vec>, identity: OutlineRowIdentity) { + if self.run_ops(ops).is_ok() { + self.restore_outline_position(&identity); + } + } + + /// Re-find `identity`'s row in the freshly rebuilt [`Self::outline_items`] and reseat + /// [`OutlineState::cursor`] there; clamps into bounds instead when the row is gone (a fully + /// discarded file drops out of the combined diff — and with it its row — entirely). Does not + /// touch [`OutlineState::focused`] — an outline-initiated op + /// only ever runs while the outline already has focus, and nothing here changes that. + fn restore_outline_position(&mut self, identity: &OutlineRowIdentity) { + let items = self.outline_items(); + let found = items.iter().position(|item| match item { + OutlineItem::File { + cs_idx, file_idx, .. + } => { + let full_path = self + .changesets + .get(*cs_idx) + .and_then(|v| v.files().get(*file_idx)) + .map(|f| f.path.as_str()); + full_path.is_some_and(|p| identity.matches_file(*cs_idx, p)) + } + OutlineItem::Dir { .. } => identity.matches_dir(item), + OutlineItem::Header { .. } => false, + }); + match found { + Some(idx) => self.outline.cursor = idx, + None => self.outline.cursor = self.outline.cursor.min(items.len().saturating_sub(1)), + } + self.derive_outline_scroll(); + } + /// Reposition (never rebuild/refocus) the outline cursor onto the row matching the CURRENT /// diff changeset+file, or clamp it into bounds if no such row exists (e.g. Flat mode /// deduped the current file's changeset out of the list). The sync-follow discipline's echo @@ -3104,6 +3379,17 @@ impl App { }; self.run_op(LineSelectionOp::new(file, selections, StageVerb::Discard)); } + PendingOp::DiscardOutlineFiles { targets, identity } => { + let ops: Vec> = targets + .iter() + .filter_map(|&(cs_idx, file_idx)| { + let file = self.changesets.get(cs_idx)?.files().get(file_idx)?.clone(); + Some(Box::new(FileStagingOp::file(file, StageVerb::Discard)) + as Box) + }) + .collect(); + self.outline_run_ops(ops, identity); + } } } @@ -3111,17 +3397,39 @@ impl App { /// surfaces on the footer and skips the refresh (the index is now in whatever partial state /// the failed op left it in — the user resolves with `r`); a `Completed` drain refreshes, /// rebuilding the views + attribution from the new index (locked decision #5), then restores - /// the reviewer's pre-op position (CS6) — a staging op is the ONE nav path that does not reset - /// to the role's first hunk; every manual nav still does, via `reset_panes` unchanged. + /// the reviewer's pre-op DIFF position (CS6) — a staging op is the ONE nav path that does not + /// reset to the role's first hunk; every manual nav still does, via `reset_panes` unchanged. /// - /// Generic over any [`StagingOp`] — a hunk/file op ([`FileStagingOp`]) or a (possibly - /// multi-hunk) line selection ([`LineSelectionOp`], which applies as ONE merged patch rather - /// than enqueueing one op per hunk — see that type's docs for why splitting is wrong). Either - /// way exactly one op is ever in flight, so the queue's trap-4 live-index staleness doesn't - /// apply — the queue is here for its lock-retry and panic isolation. + /// A thin diff-facing wrapper over [`Self::run_ops`] (one op, one memento) — the diff pane's + /// staging verbs (`s`/`S`/`d`/`D`) are the only callers, so the shared drain/refresh core + /// lives on `run_ops` and this just supplies the diff-position memento CS7's outline verbs + /// don't want (see [`Self::outline_run_ops`], which restores the OUTLINE cursor instead). fn run_op(&mut self, op: impl StagingOp + 'static) { let memento = self.capture_position(); - self.queue.enqueue(op); + if self.run_ops(vec![Box::new(op)]).is_ok() { + if let Some(memento) = memento { + self.restore_position(memento); + } + } + } + + /// Enqueue every op in `ops`, drain the queue on the same beat, and — on success — run a + /// [`Self::coordinated_refresh`]. Returns `Err` with a footer-ready message on the first + /// failure/panic in the drain (matching [`Self::run_op`]'s single-op failure contract: notice + /// text, no refresh, the index left in whatever partial state the failed op produced) and + /// `Ok(())` after a successful refresh. Callers own what happens next (a diff-position or + /// outline-cursor restore, or nothing) — this only owns the queue mechanics. + /// + /// Generic over any [`StagingOp`] — a hunk/file op ([`FileStagingOp`]), a (possibly + /// multi-hunk) line selection ([`LineSelectionOp`], which applies as ONE merged patch rather + /// than enqueueing one op per hunk — see that type's docs for why splitting is wrong), or + /// (CS7) several independent whole-file ops from an outline Dir row. The queue's trap-4 + /// live-index staleness doesn't apply here: every op resolves its own direction from the live + /// index inside `run` (see `queue.rs`'s module doc), so draining several back-to-back is safe. + fn run_ops(&mut self, ops: Vec>) -> Result<(), ()> { + for op in ops { + self.queue.enqueue(op); + } // Distinct fields (`queue` mutable, `repo`/`applier` shared) — the borrow checker permits // the disjoint borrows in one call, so the queue needn't be taken out and put back. let outcomes = self.queue.drain(&self.repo, &self.applier); @@ -3131,12 +3439,13 @@ impl App { OpOutcome::Completed(_) => None, }); match failure { - Some(message) => self.notify(message, Severity::Error), + Some(message) => { + self.notify(message, Severity::Error); + Err(()) + } None => { self.coordinated_refresh(); - if let Some(memento) = memento { - self.restore_position(memento); - } + Ok(()) } } } @@ -8777,4 +9086,319 @@ mod tests { let paths: Vec<&str> = summary.files.iter().map(|r| r.path.as_str()).collect(); assert_eq!(paths, vec!["src/a.txt", "src/b.txt"]); } + + // ── CS7: stage/unstage/discard from outline rows ───────────────────────────── + + /// Find the [`OutlineItem::File`] row index whose full path is `path` (in the CURRENT outline + /// mode/order) — the CS7 tests' stand-in for "click the row named X", since a row's raw index + /// shifts with mode/order and none of these tests want to hardcode it. + fn outline_file_row(app: &App, path: &str) -> usize { + app.outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::File { path: p, .. } if p == path)) + .unwrap_or_else(|| panic!("no outline File row for {path:?}")) + } + + #[test] + fn outline_stage_on_an_unstaged_file_row_stages_it_and_keeps_the_cursor_there() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let idx = outline_file_row(&app, "a.txt"); + open_focused_outline(&mut app, OutlineMode::Stack, idx); + + app.outline_stage(); + + assert!(app.notice.is_none(), "stage must succeed: {:?}", app.notice); + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::has_staged_file("a.txt")); + assert!( + app.outline_focused(), + "outline must keep focus across the op" + ); + match &app.outline_items()[app.outline_cursor()] { + OutlineItem::File { path, status, .. } => { + assert_eq!(path, "a.txt"); + assert_eq!( + *status, + StagedStatus::Staged, + "row now shows the staged glyph" + ); + } + other => panic!("expected the cursor to stay on a.txt's File row, got {other:?}"), + } + } + + #[test] + fn outline_stage_on_a_staged_file_row_unstages_it() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("new.txt", "hello\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let idx = outline_file_row(&app, "new.txt"); + open_focused_outline(&mut app, OutlineMode::Stack, idx); + + app.outline_stage(); + + assert!( + app.notice.is_none(), + "unstage must succeed: {:?}", + app.notice + ); + let repo = fixture.repo().unwrap(); + // An Added file has no HEAD entry, so unstaging it lands as untracked — same outcome + // `stage_file_in_staged_pane_unstages_whole_file` pins for the diff-pane path. + repo.assert(predicate::repo::has_untracked_file("new.txt")); + } + + #[test] + fn outline_stage_on_a_dir_row_stages_every_unstaged_file_under_it() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("src/a.txt", "a\n", "a\nCHANGED\n") + .unstaged_file("src/b.txt", "b\n", "b\nCHANGED\n") + .unstaged_file("top.txt", "t\n", "t\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.outline.mode = OutlineMode::StackTree; + let dir_idx = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::Dir { path, .. } if path == "src")) + .expect("src/ dir row present in StackTree mode"); + open_focused_outline(&mut app, OutlineMode::StackTree, dir_idx); + + app.outline_stage(); + + assert!( + app.notice.is_none(), + "dir stage must succeed: {:?}", + app.notice + ); + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::has_staged_file("src/a.txt")); + repo.assert(predicate::repo::has_staged_file("src/b.txt")); + // The file outside `src/` must be left alone. + repo.assert(predicate::repo::has_unstaged_file("top.txt")); + } + + #[test] + fn outline_stage_on_a_dir_row_applies_each_files_own_verb_under_mixed_status() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("src/a.txt", "a\n", "a\nCHANGED\n") + .staged_file("src/b.txt", "b\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.outline.mode = OutlineMode::StackTree; + let dir_idx = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::Dir { path, .. } if path == "src")) + .expect("src/ dir row present in StackTree mode"); + open_focused_outline(&mut app, OutlineMode::StackTree, dir_idx); + + app.outline_stage(); + + assert!( + app.notice.is_none(), + "mixed-status dir stage must succeed: {:?}", + app.notice + ); + let repo = fixture.repo().unwrap(); + // The unstaged file stages... + repo.assert(predicate::repo::has_staged_file("src/a.txt")); + // ...and the already-staged (Added, no HEAD entry) file unstages to untracked — each + // file's own verb, not a single direction applied to the whole directory. + repo.assert(predicate::repo::has_untracked_file("src/b.txt")); + } + + #[test] + fn outline_stage_on_the_header_row_refuses_without_touching_the_index() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + // Index 0 in Stack mode is always the changeset Header row. + open_focused_outline(&mut app, OutlineMode::Stack, 0); + assert!(matches!(app.outline_items()[0], OutlineItem::Header { .. })); + + app.outline_stage(); + + let notice = app + .notice + .as_ref() + .expect("staging a Header row must refuse"); + assert_eq!(notice.severity, Severity::Error); + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::has_unstaged_file("a.txt")); + } + + #[test] + fn outline_stage_on_a_committed_changesets_file_row_refuses_with_committed_wording() { + let mut app = committed_and_uncommitted_stack(); + // `BaseFirst` order + Stack mode: Header(committed) 0, File(committed/c1.txt) 1, + // Header(uncommitted) 2, File(uncommitted/u1.txt) 3. + open_focused_outline(&mut app, OutlineMode::Stack, 1); + assert!(matches!( + &app.outline_items()[1], + OutlineItem::File { cs_idx, path, .. } if *cs_idx == 0 && path == "c1.txt" + )); + + app.outline_stage(); + + let notice = app + .notice + .as_ref() + .expect("staging a committed changeset's row must refuse"); + assert_eq!(notice.severity, Severity::Error); + assert!( + notice.text.contains("already committed"), + "got: {:?}", + notice.text + ); + } + + #[test] + fn outline_discard_on_a_file_row_requests_confirm_then_y_reverts_the_worktree() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "ONE\ntwo\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let idx = outline_file_row(&app, "a.txt"); + open_focused_outline(&mut app, OutlineMode::Stack, idx); + + app.outline_discard(); + + let confirm = app + .pending_confirm + .as_ref() + .expect("discard must request a confirm"); + assert!( + confirm.prompt.contains("a.txt"), + "got: {:?}", + confirm.prompt + ); + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::workdir_file_equals("a.txt", "ONE\ntwo\n")); + + app.resolve_confirm(true); + + assert!(app.pending_confirm.is_none(), "y must clear the confirm"); + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::workdir_file_equals("a.txt", "one\ntwo\n")); + } + + #[test] + fn outline_discard_confirm_n_cancels_and_leaves_the_worktree_unchanged() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "ONE\ntwo\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let idx = outline_file_row(&app, "a.txt"); + open_focused_outline(&mut app, OutlineMode::Stack, idx); + + app.outline_discard(); + app.resolve_confirm(false); + + assert!(app.pending_confirm.is_none(), "n must clear the confirm"); + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::workdir_file_equals("a.txt", "ONE\ntwo\n")); + } + + #[test] + fn outline_discard_on_a_dir_row_names_the_scope_then_y_discards_every_file_under_it() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("src/a.txt", "a\n", "A\n") + .unstaged_file("src/b.txt", "b\n", "B\n") + .unstaged_file("top.txt", "t\n", "T\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.outline.mode = OutlineMode::StackTree; + let dir_idx = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::Dir { path, .. } if path == "src")) + .expect("src/ dir row present in StackTree mode"); + open_focused_outline(&mut app, OutlineMode::StackTree, dir_idx); + + app.outline_discard(); + + let confirm = app + .pending_confirm + .as_ref() + .expect("dir discard must request a confirm"); + assert!( + confirm.prompt.contains('2') && confirm.prompt.contains("src"), + "prompt must name the file count and the scoped path, got: {:?}", + confirm.prompt + ); + + app.resolve_confirm(true); + + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::workdir_file_equals("src/a.txt", "a\n")); + repo.assert(predicate::repo::workdir_file_equals("src/b.txt", "b\n")); + // The file outside `src/` must be left untouched. + repo.assert(predicate::repo::workdir_file_equals("top.txt", "T\n")); + } + + #[test] + fn outline_stage_in_a_multi_file_outline_keeps_the_cursor_on_the_acted_on_row_not_the_diffs_current_file( + ) { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "a\n", "a\nCHANGED\n") + .unstaged_file("b.txt", "b\n", "b\nCHANGED\n") + .unstaged_file("c.txt", "c\n", "c\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert_eq!( + app.files()[app.current].path, + "a.txt", + "the diff opens on the first file, a.txt — never touched by this test" + ); + let idx = outline_file_row(&app, "b.txt"); + open_focused_outline(&mut app, OutlineMode::Stack, idx); + + app.outline_stage(); + + assert!(app.notice.is_none(), "stage must succeed: {:?}", app.notice); + match &app.outline_items()[app.outline_cursor()] { + OutlineItem::File { path, status, .. } => { + assert_eq!( + path, "b.txt", + "the cursor must stay on the acted-on row, not drift to the diff's own \ + current file (a.txt, via sync_outline_to_current inside coordinated_refresh)" + ); + assert_eq!(*status, StagedStatus::Staged); + } + other => panic!("expected the cursor on b.txt's File row, got {other:?}"), + } + } } diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 1498b78..d2b823e 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -72,6 +72,8 @@ pub enum Command { FocusDiff, OutlineTop, OutlineBottom, + OutlineStage, + OutlineDiscard, } /// One row of the action registry: a [`Command`] with its stable config identity (`view` + @@ -321,6 +323,20 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "G", description: "Jump to the bottom of the outline", }, + Registered { + command: Command::OutlineStage, + view: View::Outline, + name: "stage", + default_keys: "s", + description: "Stage or unstage the file/directory under the cursor", + }, + Registered { + command: Command::OutlineDiscard, + view: View::Outline, + name: "discard", + default_keys: "d", + description: "Discard the file/directory under the cursor", + }, ]; /// One matchable key press: a [`KeyCode`] plus whether Ctrl/Alt are required. **Shift is diff --git a/git-workon-review/src/queue.rs b/git-workon-review/src/queue.rs index fbed8bf..282997c 100644 --- a/git-workon-review/src/queue.rs +++ b/git-workon-review/src/queue.rs @@ -68,6 +68,16 @@ pub trait StagingOp: Send { fn run(&mut self, ctx: &OpContext<'_>) -> Result<(), ApplyError>; } +/// Lets an already-boxed trait object be re-enqueued through [`StagingQueue::enqueue`] (which +/// takes `impl StagingOp + 'static` and boxes internally) without unboxing first — CS7's +/// `App::run_ops` collects a `Vec>` of heterogeneous per-file ops (one +/// [`crate::stage_op::FileStagingOp`] per outline target) and enqueues them one at a time. +impl StagingOp for Box { + fn run(&mut self, ctx: &OpContext<'_>) -> Result<(), ApplyError> { + (**self).run(ctx) + } +} + /// The result of running one queued op. #[derive(Debug)] pub enum OpOutcome { diff --git a/git-workon-review/src/summary.rs b/git-workon-review/src/summary.rs index 8ac9e7f..9e073c8 100644 --- a/git-workon-review/src/summary.rs +++ b/git-workon-review/src/summary.rs @@ -123,7 +123,11 @@ pub struct DirSummary { /// Segment-boundary match: `file_path` is "under" `dir_path` only when `dir_path` is a full path /// SEGMENT prefix of `file_path` — `"src"` matches `"src/a.rs"` but must NOT match `"src2/b.rs"` /// (a raw [`str::starts_with`] would wrongly match the latter). -fn path_is_under(file_path: &str, dir_path: &str) -> bool { +/// +/// `pub(crate)`: CS7's `App::outline_row_targets` reuses this to resolve a Dir row's files +/// (`s`/`d` in the outline), the same segment-boundary rule [`dir_summary`] already relies on — +/// rather than re-deriving it in `app.rs`. +pub(crate) fn path_is_under(file_path: &str, dir_path: &str) -> bool { file_path .strip_prefix(dir_path) .and_then(|rest| rest.strip_prefix('/')) diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 86d99a4..d764453 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -421,6 +421,8 @@ enum Action { FocusDiff, OutlineTop, OutlineBottom, + OutlineStage, + OutlineDiscard, None, } @@ -463,6 +465,8 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::FocusDiff => Action::FocusDiff, Command::OutlineTop => Action::OutlineTop, Command::OutlineBottom => Action::OutlineBottom, + Command::OutlineStage => Action::OutlineStage, + Command::OutlineDiscard => Action::OutlineDiscard, } } @@ -578,6 +582,8 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::FocusDiff => app.focus_diff(), Action::OutlineTop => app.outline_top(), Action::OutlineBottom => app.outline_bottom(), + Action::OutlineStage => app.outline_stage(), + Action::OutlineDiscard => app.outline_discard(), Action::None => {} } false From cbd16cd2dafc09750628557a64fd2322eca81a65 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 23:18:35 -0400 Subject: [PATCH 114/203] fix(review): re-resolve outline discard targets at confirm time --- git-workon-review/src/app.rs | 191 ++++++++++++++++++++++++----------- 1 file changed, 131 insertions(+), 60 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index c7ced42..4fab891 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1112,16 +1112,17 @@ pub enum PendingOp { file_idx: usize, selections: Vec<(usize, LineSelection)>, }, - /// CS7: discard every `(cs_idx, file_idx)` in `targets` from the worktree — an outline File - /// row's single target, or a Dir row's every file under its path. `identity` is the acted-on - /// outline row's [`OutlineRowIdentity`], captured at request-time (before the confirm modal), - /// so [`App::resolve_confirm`] can hand it to [`App::outline_run_ops`] for the post-op outline - /// cursor restore — by the time `y`/`n` answers the modal, the outline cursor may not still be - /// resting on the row that requested the discard (nothing else moves it in between today, but - /// baking the identity in here rather than re-reading `self.outline.cursor` avoids relying on - /// that). + /// CS7: discard every file in `files` — `(changeset name, file path)` pairs — from the + /// worktree: an outline File row's single target, or a Dir row's every file under its path. + /// Stored by NAME + PATH rather than raw `(cs_idx, file_idx)` indices because the confirm + /// modal doesn't stop the tick beat: an external index change (e.g. `git add` from another + /// terminal) can run a full refresh between `d` and `y`, rebuilding the per-changeset file + /// lists and shifting positions — [`App::resolve_confirm`] re-resolves each pair against the + /// LIVE changesets at answer time (silently skipping any that vanished) so a stale index can + /// never discard the wrong file. `identity` is the acted-on outline row's + /// [`OutlineRowIdentity`], captured at request-time for the post-op outline cursor restore. DiscardOutlineFiles { - targets: Vec<(usize, usize)>, + files: Vec<(String, String)>, identity: OutlineRowIdentity, }, } @@ -2723,6 +2724,32 @@ impl App { } } + /// The shared resolve-and-gate preamble of the outline staging verbs (`s`/`d`): resolve the + /// row under the outline cursor to its identity + targets, refusing (with `verb` naming the + /// action in the notice) on a Header row or when any target belongs to a committed changeset, + /// and bailing silently on an empty target list. One helper so the two verbs' gates can't + /// drift apart. + fn outline_verb_targets( + &mut self, + verb: &str, + ) -> Option<(OutlineRowIdentity, Vec<(usize, usize)>)> { + let Some((identity, targets)) = self.outline_row_targets(self.outline.cursor) else { + self.notify_outline_refusal(verb, false); + return None; + }; + if targets + .iter() + .any(|&(cs_idx, _)| self.is_committed_at(cs_idx)) + { + self.notify_outline_refusal(verb, true); + return None; + } + if targets.is_empty() { + return None; + } + Some((identity, targets)) + } + /// `s` while the outline has focus: stage or unstage the file/directory under the cursor. A /// [`OutlineItem::File`] row stages or unstages per its own [`Self::outline_target_verb`]; a /// [`OutlineItem::Dir`] row applies the same per-file verb selection to every file under it @@ -2730,21 +2757,9 @@ impl App { /// or all-unstage op). Refuses on a [`OutlineItem::Header`] row or when any target belongs to /// a committed changeset (see [`Self::notify_outline_refusal`]). pub fn outline_stage(&mut self) { - let idx = self.outline.cursor; - let Some((identity, targets)) = self.outline_row_targets(idx) else { - self.notify_outline_refusal("stage", false); + let Some((identity, targets)) = self.outline_verb_targets("stage") else { return; }; - if targets - .iter() - .any(|&(cs_idx, _)| self.is_committed_at(cs_idx)) - { - self.notify_outline_refusal("stage", true); - return; - } - if targets.is_empty() { - return; - } let ops: Vec> = targets .iter() .filter_map(|&(cs_idx, file_idx)| { @@ -2762,21 +2777,9 @@ impl App { /// scope. Same refusal gates as [`Self::outline_stage`]. The discard itself runs when the user /// answers `y` (see [`Self::resolve_confirm`]'s [`PendingOp::DiscardOutlineFiles`] arm). pub fn outline_discard(&mut self) { - let idx = self.outline.cursor; - let Some((identity, targets)) = self.outline_row_targets(idx) else { - self.notify_outline_refusal("discard", false); + let Some((identity, targets)) = self.outline_verb_targets("discard") else { return; }; - if targets - .iter() - .any(|&(cs_idx, _)| self.is_committed_at(cs_idx)) - { - self.notify_outline_refusal("discard", true); - return; - } - if targets.is_empty() { - return; - } let prompt = match &identity { OutlineRowIdentity::File { path, .. } => { format!("Discard all changes to `{path}`? (y/n)") @@ -2786,11 +2789,19 @@ impl App { targets.len() ), }; - self.request_confirm(prompt, PendingOp::DiscardOutlineFiles { targets, identity }); + let files: Vec<(String, String)> = targets + .iter() + .filter_map(|&(cs_idx, file_idx)| { + let view = self.changesets.get(cs_idx)?; + let path = view.files().get(file_idx)?.path.clone(); + Some((view.cs.name.clone(), path)) + }) + .collect(); + self.request_confirm(prompt, PendingOp::DiscardOutlineFiles { files, identity }); } /// The outline-facing counterpart to [`Self::run_op`]: drain `ops` through [`Self::run_ops`], - /// then — on success — restore the OUTLINE cursor to (or nearest to) `identity`'s row rather + /// then restore the OUTLINE cursor to (or nearest to) `identity`'s row rather /// than a diff-pane position (CS6's [`PositionMemento`]/[`Self::restore_position`] only make /// sense when the diff pane, not the outline, was the focused surface the op started from). /// [`Self::coordinated_refresh`] (inside `run_ops`) itself calls `sync_outline_to_current`, @@ -2798,9 +2809,12 @@ impl App { /// file happens to be) — this runs after that and overwrites it with the acted-on row's own /// position, or the nearest surviving row if it's gone (e.g. a fully-discarded file). fn outline_run_ops(&mut self, ops: Vec>, identity: OutlineRowIdentity) { - if self.run_ops(ops).is_ok() { - self.restore_outline_position(&identity); - } + let pre_op_cursor = self.outline.cursor; + // Restore after BOTH outcomes: `run_ops` refreshes (and thereby yanks the outline cursor + // via `sync_outline_to_current`) even on a partial failure, and the acted-on row is where + // the user is looking either way. + let _ = self.run_ops(ops); + self.restore_outline_position(&identity, pre_op_cursor); } /// Re-find `identity`'s row in the freshly rebuilt [`Self::outline_items`] and reseat @@ -2808,7 +2822,7 @@ impl App { /// discarded file drops out of the combined diff — and with it its row — entirely). Does not /// touch [`OutlineState::focused`] — an outline-initiated op /// only ever runs while the outline already has focus, and nothing here changes that. - fn restore_outline_position(&mut self, identity: &OutlineRowIdentity) { + fn restore_outline_position(&mut self, identity: &OutlineRowIdentity, pre_op_cursor: usize) { let items = self.outline_items(); let found = items.iter().position(|item| match item { OutlineItem::File { @@ -2826,7 +2840,12 @@ impl App { }); match found { Some(idx) => self.outline.cursor = idx, - None => self.outline.cursor = self.outline.cursor.min(items.len().saturating_sub(1)), + // Row gone (the NORMAL outcome of a successful discard — the file left the combined + // diff and took its row with it): stay near where the user was ACTING, not wherever + // the refresh's `sync_outline_to_current` just parked the cursor (the diff's current + // file, unrelated to the acted-on row). `pre_op_cursor` is the acted-on row's own + // pre-op position; clamping it lands on the nearest surviving neighbor. + None => self.outline.cursor = pre_op_cursor.min(items.len().saturating_sub(1)), } self.derive_outline_scroll(); } @@ -3379,11 +3398,16 @@ impl App { }; self.run_op(LineSelectionOp::new(file, selections, StageVerb::Discard)); } - PendingOp::DiscardOutlineFiles { targets, identity } => { - let ops: Vec> = targets + PendingOp::DiscardOutlineFiles { files, identity } => { + // Re-resolve each (changeset name, path) pair against the LIVE changesets — an + // intervening tick refresh may have shifted every index since `d` was pressed + // (see the variant's doc); a pair that no longer resolves is silently skipped + // (its file already left the diff, so there's nothing left to discard). + let ops: Vec> = files .iter() - .filter_map(|&(cs_idx, file_idx)| { - let file = self.changesets.get(cs_idx)?.files().get(file_idx)?.clone(); + .filter_map(|(cs_name, path)| { + let view = self.changesets.iter().find(|v| v.cs.name == *cs_name)?; + let file = view.files().iter().find(|f| f.path == *path)?.clone(); Some(Box::new(FileStagingOp::file(file, StageVerb::Discard)) as Box) }) @@ -3394,11 +3418,11 @@ impl App { } /// Enqueue `op`, drain the queue on the same beat, then act on the outcome: a failure or panic - /// surfaces on the footer and skips the refresh (the index is now in whatever partial state - /// the failed op left it in — the user resolves with `r`); a `Completed` drain refreshes, - /// rebuilding the views + attribution from the new index (locked decision #5), then restores - /// the reviewer's pre-op DIFF position (CS6) — a staging op is the ONE nav path that does not - /// reset to the role's first hunk; every manual nav still does, via `reset_panes` unchanged. + /// surfaces on the footer (and the views still refresh — see [`Self::run_ops`] for why); a + /// `Completed` drain refreshes, rebuilding the views + attribution from the new index (locked + /// decision #5), then restores the reviewer's pre-op DIFF position (CS6) — a staging op is + /// the ONE nav path that does not reset to the role's first hunk; every manual nav still + /// does, via `reset_panes` unchanged. /// /// A thin diff-facing wrapper over [`Self::run_ops`] (one op, one memento) — the diff pane's /// staging verbs (`s`/`S`/`d`/`D`) are the only callers, so the shared drain/refresh core @@ -3413,11 +3437,11 @@ impl App { } } - /// Enqueue every op in `ops`, drain the queue on the same beat, and — on success — run a - /// [`Self::coordinated_refresh`]. Returns `Err` with a footer-ready message on the first - /// failure/panic in the drain (matching [`Self::run_op`]'s single-op failure contract: notice - /// text, no refresh, the index left in whatever partial state the failed op produced) and - /// `Ok(())` after a successful refresh. Callers own what happens next (a diff-position or + /// Enqueue every op in `ops`, drain the queue on the same beat, then run a + /// [`Self::coordinated_refresh`] REGARDLESS of outcome — the drain never stops on a failure, + /// so a partial multi-op batch has already mutated the index/worktree and the views must + /// re-read that reality even while a failure notice shows. Returns `Err` after notifying the + /// first failure/panic, `Ok(())` otherwise. Callers own what happens next (a diff-position or /// outline-cursor restore, or nothing) — this only owns the queue mechanics. /// /// Generic over any [`StagingOp`] — a hunk/file op ([`FileStagingOp`]), a (possibly @@ -3438,15 +3462,18 @@ impl App { OpOutcome::Panicked(_) => Some("staging operation panicked".to_string()), OpOutcome::Completed(_) => None, }); + // Refresh in BOTH arms: the queue's drain never stops on a failure (`pump` runs every + // queued op regardless), so in a multi-op batch a single failure still leaves up to N-1 + // other ops applied to the index/worktree — the views must re-read that reality even + // while the failure notice shows. (For a single-op batch the refresh is a harmless + // re-read of unchanged state.) + self.coordinated_refresh(); match failure { Some(message) => { self.notify(message, Severity::Error); Err(()) } - None => { - self.coordinated_refresh(); - Ok(()) - } + None => Ok(()), } } @@ -9306,6 +9333,50 @@ mod tests { repo.assert(predicate::repo::workdir_file_equals("a.txt", "one\ntwo\n")); } + #[test] + fn outline_discard_survives_an_intervening_refresh_that_shifts_file_indices() { + // The confirm modal doesn't stop the tick beat: an external index change can trigger a + // full refresh between `d` and `y`, shifting every (cs_idx, file_idx). The pending op + // stores (changeset name, path) pairs and re-resolves at answer time, so the discard + // must still hit the file it was requested on — not whatever now sits at its old index. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("b.txt", "one\ntwo\n", "ONE\ntwo\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let idx = outline_file_row(&app, "b.txt"); + open_focused_outline(&mut app, OutlineMode::Stack, idx); + + app.outline_discard(); + assert!(app.pending_confirm.is_some()); + + // A new modified file that sorts BEFORE b.txt enters the diff while the confirm is up, + // then a refresh rebuilds the file lists — b.txt's file_idx shifts by one. + let repo = fixture.repo().unwrap(); + let workdir = repo.workdir().unwrap(); + std::fs::write(workdir.join("a.txt"), "NEW\n").unwrap(); + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + index.write().unwrap(); + std::fs::write(workdir.join("a.txt"), "NEW\nCHANGED\n").unwrap(); + app.refresh(); + assert!( + app.pending_confirm.is_some(), + "the refresh must not consume the pending confirm" + ); + + app.resolve_confirm(true); + + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::workdir_file_equals("b.txt", "one\ntwo\n")); + repo.assert(predicate::repo::workdir_file_equals( + "a.txt", + "NEW\nCHANGED\n", + )); + } + #[test] fn outline_discard_confirm_n_cancels_and_leaves_the_worktree_unchanged() { let fixture = FixtureBuilder::new() From 0ead53b86b68489c64044cce3d56d073f4f649d4 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 23:41:25 -0400 Subject: [PATCH 115/203] fix(review): adopt FileOccurrence and scroll arity from downstack --- git-workon-review/src/app.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 4fab891..7088338 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -2373,9 +2373,7 @@ impl App { entries.sort_by(|a, b| a.0.cmp(b.0)); let files: Vec<&FileChange> = entries .into_iter() - .filter_map(|(_, occ)| { - self.changesets[occ.cs_idx].files().get(occ.file_idx) - }) + .filter_map(|(_, occ)| self.changesets[occ.cs_idx].files().get(occ.file_idx)) .collect(); Summary::Dir(summary::dir_summary(path, &files)) } @@ -2682,7 +2680,7 @@ impl App { latest .iter() .filter(|(p, _)| summary::path_is_under(p, path)) - .map(|(_, &(cs_idx, file_idx, _, _))| (cs_idx, file_idx)) + .map(|(_, occ)| (occ.cs_idx, occ.file_idx)) .collect() } }; @@ -2847,7 +2845,7 @@ impl App { // pre-op position; clamping it lands on the nearest surviving neighbor. None => self.outline.cursor = pre_op_cursor.min(items.len().saturating_sub(1)), } - self.derive_outline_scroll(); + self.derive_outline_scroll(items.len()); } /// Reposition (never rebuild/refocus) the outline cursor onto the row matching the CURRENT From c3674fbf1d32f4af58d413d79026d84a03f44fe0 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 21:02:06 -0400 Subject: [PATCH 116/203] feat(review): expand collapsed context gaps progressively --- git-workon-review/src/align.rs | 340 +++++++++++++++++++++++++++++-- git-workon-review/src/app.rs | 341 +++++++++++++++++++++++++++++--- git-workon-review/src/keymap.rs | 16 ++ git-workon-review/src/render.rs | 12 +- git-workon-review/src/tui.rs | 39 ++++ 5 files changed, 699 insertions(+), 49 deletions(-) diff --git a/git-workon-review/src/align.rs b/git-workon-review/src/align.rs index 931bd10..6e26159 100644 --- a/git-workon-review/src/align.rs +++ b/git-workon-review/src/align.rs @@ -21,6 +21,20 @@ //! (`Patch::line_in_hunk`'s `old_lineno`/`new_lineno`), not something this module can violate, //! so the pairing code below `expect()`s the lineno for the side each kind is documented to //! carry. +//! +//! ## Progressive gap expansion (CS8) +//! +//! [`collapse_gaps`]'s collapsed [`DisplayRow::Gap`]/[`InlineRow::Gap`] markers each carry a +//! `key` — the hidden run's start index in the pre-collapse [`AlignedRow`] space — so a caller +//! can ask for MORE of that specific run to be revealed without losing track of it as it widens. +//! [`collapse_gaps_with_expansions`] takes a `key -> `[`GapExpansion`]` map and re-collapses each +//! run against its entry (if any): `before`/`after` grow the kept window at that edge, `full` +//! reveals the whole run. `collapse_gaps` itself is the empty-map case. State ownership (which +//! gaps are expanded, and by how much) lives OUTSIDE this module, in +//! [`crate::app::FileView::expansions`] — this module stays pure, taking the map as input rather +//! than mutating anything. + +use std::collections::HashMap; use crate::model::{Hunk, HunkLine, LineKind}; @@ -183,16 +197,37 @@ pub fn align_file(hunks: &[Hunk], old_line_count: usize, new_line_count: usize) /// /// Unchanged stretches longer than `2 * CONTEXT_LINES` collapse to a single [`DisplayRow::Gap`] /// so the view doesn't scroll through pages of untouched code. Gap rows are layout-agnostic — -/// they span both panes in SBS. +/// they span both panes in SBS. `key` identifies the collapsed run so a caller can request it be +/// progressively revealed — see [`GapExpansion`] and [`collapse_gaps_with_expansions`]. #[derive(Debug, Clone, Copy)] pub enum DisplayRow { Row(AlignedRow), - Gap { skipped: usize }, + Gap { key: usize, skipped: usize }, } /// Number of context lines kept around hunk content on each side of a gap. pub const CONTEXT_LINES: usize = 3; +/// How far a single collapsed gap has been expanded (CS8). Accumulates across repeated `Enter` +/// presses: `before`/`after` each independently widen how many rows are revealed at that edge of +/// the gap, and `full` — once set — reveals the whole run regardless of `before`/`after`. +/// +/// Keyed in the caller's map by the SAME `key` [`DisplayRow::Gap`]/[`InlineRow::Gap`] carry: the +/// hidden run's start index in the pre-collapse [`AlignedRow`] space. That space never changes +/// shape as a gap widens (only how much of it stays hidden changes), so the key stays valid +/// across repeated expansion requests for the same gap. +#[derive(Debug, Clone, Copy, Default)] +pub struct GapExpansion { + /// Extra rows revealed at the gap's leading edge (extends the visible context below the + /// preceding hunk downward, growing the row range kept immediately after `run_start`). + pub before: usize, + /// Extra rows revealed at the gap's trailing edge (extends the visible context above the + /// following hunk upward, growing the row range kept immediately before `run_end`). + pub after: usize, + /// Reveal every row in the run, ignoring `before`/`after`. + pub full: bool, +} + /// Collapse long unchanged stretches in `rows` into [`DisplayRow::Gap`] markers, keeping /// [`CONTEXT_LINES`] rows of context immediately around hunk content (Del/Add/Filler rows). /// @@ -200,12 +235,32 @@ pub const CONTEXT_LINES: usize = 3; /// (enough to keep `CONTEXT_LINES` on both sides of the gap); shorter stretches, including ones /// between two hunks that are close together, are left as-is (no gap row — the hunks /// effectively merge under one continuous context run). +/// +/// Thin wrapper over [`collapse_gaps_with_expansions`] with no expansions applied. pub fn collapse_gaps(rows: &[AlignedRow]) -> Vec { - collapse_gaps_with(rows, CONTEXT_LINES) + collapse_gaps_with_expansions(rows, &HashMap::new()) +} + +/// Same as [`collapse_gaps`], but a gap whose key has an entry in `expansions` reveals extra rows +/// at its edges (or its whole run) instead of collapsing to the base [`CONTEXT_LINES`] window — +/// see [`GapExpansion`]. +pub fn collapse_gaps_with_expansions( + rows: &[AlignedRow], + expansions: &HashMap, +) -> Vec { + collapse_gaps_inner(rows, CONTEXT_LINES, expansions) } -/// Same as [`collapse_gaps`] but with an explicit context-line count, for testing. +/// Same as [`collapse_gaps_with_expansions`] but with an explicit context-line count, for testing. fn collapse_gaps_with(rows: &[AlignedRow], context: usize) -> Vec { + collapse_gaps_inner(rows, context, &HashMap::new()) +} + +fn collapse_gaps_inner( + rows: &[AlignedRow], + context: usize, + expansions: &HashMap, +) -> Vec { let is_context = |row: &AlignedRow| { matches!( (row.old_kind, row.new_kind), @@ -243,13 +298,31 @@ fn collapse_gaps_with(rows: &[AlignedRow], context: usize) -> Vec { for row in &rows[run_start..run_end] { out.push(DisplayRow::Row(*row)); } + i = run_end; + continue; + } + + // This run collapses to a gap (before any expansion is applied) — the key is stable + // across future expansion requests, so compute it once here. + let key = run_start; + let expansion = expansions.get(&key).copied().unwrap_or_default(); + + let effective_before = (keep_before + expansion.before).min(run_len); + let effective_after = (keep_after + expansion.after).min(run_len - effective_before); + + if expansion.full || effective_before + effective_after >= run_len { + // The expansion consumes the whole run (or was asked to): no gap left worth + // collapsing, emit every row. + for row in &rows[run_start..run_end] { + out.push(DisplayRow::Row(*row)); + } } else { - for row in &rows[run_start..run_start + keep_before] { + for row in &rows[run_start..run_start + effective_before] { out.push(DisplayRow::Row(*row)); } - let skipped = run_len - keep_before - keep_after; - out.push(DisplayRow::Gap { skipped }); - for row in &rows[run_end - keep_after..run_end] { + let skipped = run_len - effective_before - effective_after; + out.push(DisplayRow::Gap { key, skipped }); + for row in &rows[run_end - effective_after..run_end] { out.push(DisplayRow::Row(*row)); } } @@ -290,6 +363,7 @@ pub enum InlineRow { paired_old: Option, }, Gap { + key: usize, skipped: usize, }, } @@ -348,9 +422,12 @@ pub fn inline_rows(display: &[DisplayRow]) -> Vec { for row in display { match row { - DisplayRow::Gap { skipped } => { + DisplayRow::Gap { key, skipped } => { flush(&mut run, &mut out); - out.push(InlineRow::Gap { skipped: *skipped }); + out.push(InlineRow::Gap { + key: *key, + skipped: *skipped, + }); } DisplayRow::Row(r) if r.old_kind == CellKind::Context && r.new_kind == CellKind::Context => @@ -540,7 +617,7 @@ mod tests { assert!(matches!(row, DisplayRow::Row(r) if r.old_kind == CellKind::Context)); } match display[4] { - DisplayRow::Gap { skipped } => assert_eq!(skipped, 4), + DisplayRow::Gap { skipped, .. } => assert_eq!(skipped, 4), other => panic!("expected gap row, got {other:?}"), } for row in &display[5..8] { @@ -608,7 +685,7 @@ mod tests { // gap, 3 ctx, change assert_eq!(display.len(), 5); match display[0] { - DisplayRow::Gap { skipped } => assert_eq!(skipped, 7), + DisplayRow::Gap { skipped, .. } => assert_eq!(skipped, 7), other => panic!("expected gap row, got {other:?}"), } for row in &display[1..4] { @@ -635,7 +712,7 @@ mod tests { assert!(matches!(row, DisplayRow::Row(_))); } match display[4] { - DisplayRow::Gap { skipped } => assert_eq!(skipped, 7), + DisplayRow::Gap { skipped, .. } => assert_eq!(skipped, 7), other => panic!("expected gap row, got {other:?}"), } } @@ -739,8 +816,243 @@ mod tests { assert!( inline .iter() - .any(|r| matches!(r, InlineRow::Gap { skipped: 4 })), + .any(|r| matches!(r, InlineRow::Gap { skipped: 4, .. })), "expected the gap row to survive the inline conversion unchanged: {inline:?}" ); } + + // ── CS8: progressive gap expansion ────────────────────────────────────── + + /// One change row, a run of `run_len` context rows, one more change row — the shape every + /// CS8 expansion test collapses. With `context = 3` the base hidden count is + /// `run_len - 2 * 3`. + fn change_then_context_run_then_change(run_len: usize) -> Vec { + let mut rows = vec![change_row( + Row::Line(1), + Row::Line(1), + CellKind::Del, + CellKind::Add, + )]; + rows.extend((2..=run_len + 1).map(context_row)); + rows.push(change_row( + Row::Line(run_len + 2), + Row::Line(run_len + 2), + CellKind::Del, + CellKind::Add, + )); + rows + } + + #[test] + fn collapse_gaps_matches_collapse_gaps_with_expansions_over_an_empty_map() { + // `collapse_gaps` is a thin wrapper — pin that it's byte-for-byte the same output as + // calling the expansion-aware entry point with nothing to expand (the pre-CS8 behavior + // every other test in this module already exercises via `collapse_gaps_with`). + let rows = change_then_context_run_then_change(16); + let via_collapse_gaps = collapse_gaps(&rows); + let via_expansions = collapse_gaps_with_expansions(&rows, &HashMap::new()); + assert_eq!(via_collapse_gaps.len(), via_expansions.len()); + for (a, b) in via_collapse_gaps.iter().zip(via_expansions.iter()) { + match (a, b) { + ( + DisplayRow::Gap { + key: ka, + skipped: sa, + }, + DisplayRow::Gap { + key: kb, + skipped: sb, + }, + ) => { + assert_eq!(ka, kb); + assert_eq!(sa, sb); + } + (DisplayRow::Row(ra), DisplayRow::Row(rb)) => { + assert_eq!(ra.old, rb.old); + assert_eq!(ra.new, rb.new); + } + _ => panic!("row kind mismatch: {a:?} vs {b:?}"), + } + } + } + + /// The single [`DisplayRow::Gap`]'s `(key, skipped)` in `display` — the CS8 expansion tests' + /// index-free lookup (the gap's display position depends on how much kept context precedes + /// it, which is exactly what these tests vary). + fn only_gap(display: &[DisplayRow]) -> (usize, usize) { + display + .iter() + .find_map(|r| match r { + DisplayRow::Gap { key, skipped } => Some((*key, *skipped)), + _ => None, + }) + .expect("expected a gap row") + } + + #[test] + fn partial_expansion_reveals_rows_at_both_edges_and_shrinks_skipped() { + // run_len = 16 -> base hidden (K) = 16 - 3 - 3 = 10. + let rows = change_then_context_run_then_change(16); + let (key, base_skipped) = only_gap(&collapse_gaps(&rows)); + assert_eq!(base_skipped, 10, "base hidden count (K)"); + + let mut expansions = HashMap::new(); + expansions.insert( + key, + GapExpansion { + before: 3, + after: 2, + full: false, + }, + ); + let display = collapse_gaps_with_expansions(&rows, &expansions); + + // change, 3 base + 3 revealed before = 6 kept-before rows, gap, 3 base + 2 revealed + // after = 5 kept-after rows, change. + assert_eq!(display.len(), 1 + 6 + 1 + 5 + 1); + for row in &display[1..7] { + assert!(matches!(row, DisplayRow::Row(_))); + } + match display[7] { + DisplayRow::Gap { + key: gap_key, + skipped, + } => { + assert_eq!( + gap_key, key, + "the gap's key must not change across expansion" + ); + assert_eq!(skipped, 5, "K - 5 == 10 - (3 + 2)"); + } + other => panic!("expected a gap row, got {other:?}"), + } + for row in &display[8..13] { + assert!(matches!(row, DisplayRow::Row(_))); + } + assert!(matches!(display[13], DisplayRow::Row(_))); + } + + #[test] + fn widening_an_expansion_accumulates_and_shrinks_skipped_further() { + let rows = change_then_context_run_then_change(20); // K = 20 - 6 = 14 + let (key, _) = only_gap(&collapse_gaps(&rows)); + + // First press: reveal 5 more rows at the leading edge. + let mut expansions = HashMap::new(); + expansions.insert( + key, + GapExpansion { + before: 5, + after: 0, + full: false, + }, + ); + let after_first = collapse_gaps_with_expansions(&rows, &expansions); + let skipped_after_first = match after_first + .iter() + .find(|r| matches!(r, DisplayRow::Gap { .. })) + { + Some(DisplayRow::Gap { skipped, .. }) => *skipped, + _ => panic!("expected a surviving gap row after the first press"), + }; + assert_eq!(skipped_after_first, 14 - 5); + + // Second press accumulates on top of the first (mirrors `FileView::expand_gap`'s + // `entry.before += more_before`), rather than replacing it. + expansions.get_mut(&key).unwrap().before += 5; + let after_second = collapse_gaps_with_expansions(&rows, &expansions); + let skipped_after_second = match after_second + .iter() + .find(|r| matches!(r, DisplayRow::Gap { .. })) + { + Some(DisplayRow::Gap { skipped, .. }) => *skipped, + _ => panic!("expected a surviving gap row after the second press"), + }; + assert_eq!(skipped_after_second, 14 - 10); + assert!(skipped_after_second < skipped_after_first); + } + + #[test] + fn full_expansion_removes_the_gap_row_entirely() { + let rows = change_then_context_run_then_change(16); + let (key, _) = only_gap(&collapse_gaps(&rows)); + let mut expansions = HashMap::new(); + expansions.insert( + key, + GapExpansion { + before: 0, + after: 0, + full: true, + }, + ); + let display = collapse_gaps_with_expansions(&rows, &expansions); + assert!( + display.iter().all(|r| matches!(r, DisplayRow::Row(_))), + "a full expansion must emit every row, no Gap: {display:?}" + ); + assert_eq!(display.len(), rows.len()); + } + + #[test] + fn expansion_consuming_the_whole_run_removes_the_gap_row_without_full() { + // K = 10; before + after (6 + 4 = 10) exactly covers the hidden run without `full`. + let rows = change_then_context_run_then_change(16); + let (key, _) = only_gap(&collapse_gaps(&rows)); + let mut expansions = HashMap::new(); + expansions.insert( + key, + GapExpansion { + before: 6, + after: 4, + full: false, + }, + ); + let display = collapse_gaps_with_expansions(&rows, &expansions); + assert!( + display.iter().all(|r| matches!(r, DisplayRow::Row(_))), + "before + after covering the whole run must emit every row, no Gap: {display:?}" + ); + assert_eq!(display.len(), rows.len()); + } + + #[test] + fn inline_mirror_stays_consistent_with_the_same_expansions_map() { + let rows = change_then_context_run_then_change(16); + let (key, _) = only_gap(&collapse_gaps(&rows)); + let mut expansions = HashMap::new(); + expansions.insert( + key, + GapExpansion { + before: 3, + after: 2, + full: false, + }, + ); + let display = collapse_gaps_with_expansions(&rows, &expansions); + let inline = inline_rows(&display); + + // The SBS gap and the inline gap must carry the same key and skipped count — inline + // reuses the same gap-collapsed `display` vector rather than re-deriving gaps itself. + let sbs_gap = display + .iter() + .find_map(|r| match r { + DisplayRow::Gap { key, skipped } => Some((*key, *skipped)), + _ => None, + }) + .expect("expected a surviving SBS gap"); + let inline_gap = inline + .iter() + .find_map(|r| match r { + InlineRow::Gap { key, skipped } => Some((*key, *skipped)), + _ => None, + }) + .expect("expected a surviving inline gap"); + assert_eq!(sbs_gap, inline_gap); + + // Context rows revealed at the leading edge (old=2..=4, new=2..=4 in this fixture) show + // up as `InlineRow::Context` entries before the inline gap. + assert!(inline + .iter() + .any(|r| matches!(r, InlineRow::Context { old: 4, new: 4 }))); + } } diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 7088338..bd26031 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -16,7 +16,10 @@ use git2::Repository; use workon::{Changeset, ChangesetSpan}; use crate::acquire::{ChangesetDiff, WorktreeDiffs}; -use crate::align::{align_file, collapse_gaps, inline_rows, CellKind, DisplayRow, InlineRow, Row}; +use crate::align::{ + align_file, collapse_gaps_with_expansions, inline_rows, AlignedRow, CellKind, DisplayRow, + GapExpansion, InlineRow, Row, +}; use crate::apply::{Git2Applier, StageVerb}; use crate::config::RawViewConfig; use crate::highlight::{FgSpan, TsHighlighter}; @@ -52,6 +55,20 @@ const SCROLLOFF: usize = 2; /// staged/unstaged split zoom). #[derive(Debug)] pub struct FileView { + /// The pre-collapse row list [`Self::display`]/[`Self::inline`] derive from — retained (CS8) + /// so a gap can be re-collapsed with a wider [`GapExpansion`] window without re-diffing the + /// file. `AlignedRow` is small/`Copy`, so cloning the whole vector per expansion is cheap + /// relative to re-running `align_file`. + aligned: Vec, + /// Per-gap expansion requests, keyed by the hidden run's start index in [`Self::aligned`] + /// (the same key [`DisplayRow::Gap`]/[`InlineRow::Gap`] carry). Reset to empty on every + /// [`Self::load`] — expansions are NOT preserved across a refresh; the view rebuilds from + /// scratch and every gap re-collapses to its base window. See [`Self::expand_gap`]. + expansions: HashMap, + /// The file's hunks, retained (CS8) alongside [`Self::aligned`] so [`Self::rebuild_rows`] can + /// recompute [`Self::display_hunk`]/[`Self::inline_hunk`] after an expansion without needing + /// the original [`FileChange`] back. + hunks: Vec, old_text: String, new_text: String, old_lines: Vec, @@ -136,9 +153,45 @@ impl FileView { let old_lines: Vec = old_text.lines().map(str::to_string).collect(); let new_lines: Vec = new_text.lines().map(str::to_string).collect(); - let aligned = align_file(&file.hunks, old_lines.len(), new_lines.len()); - let display = collapse_gaps(&aligned.rows); - let first_hunk_row = display + let aligned = align_file(&file.hunks, old_lines.len(), new_lines.len()).rows; + let old_hl = ts.highlight_file(old_source_path, &old_text); + let new_hl = ts.highlight_file(&file.path, &new_text); + + let mut view = Self { + aligned, + expansions: HashMap::new(), + hunks: file.hunks.clone(), + old_text, + new_text, + old_lines, + new_lines, + display: Vec::new(), + first_hunk_row: 0, + first_inline_hunk_row: 0, + old_hl, + new_hl, + word_spans: HashMap::new(), + inline: Vec::new(), + inline_word_spans: HashMap::new(), + display_hunk: Vec::new(), + inline_hunk: Vec::new(), + }; + view.rebuild_rows(); + view + } + + /// Recompute [`Self::display`]/[`Self::inline`] (and everything derived from them) from + /// [`Self::aligned`] + [`Self::expansions`] — called once at [`Self::load`] and again after + /// every [`Self::expand_gap`]. Row-keyed word-span caches are cleared: an expansion changes + /// which display/inline index a given content row lands at, so a cached span keyed by the OLD + /// index would silently mismatch the row it renders under. The highlight caches + /// ([`Self::old_hl`]/[`Self::new_hl`]) are source-line-indexed (one entry per line of the full + /// old/new text), not row-indexed, so an expansion — which only changes how many already-hl'd + /// lines are VISIBLE — never invalidates them. + fn rebuild_rows(&mut self) { + self.display = collapse_gaps_with_expansions(&self.aligned, &self.expansions); + self.first_hunk_row = self + .display .iter() .position(|row| { matches!( @@ -148,45 +201,47 @@ impl FileView { }) .unwrap_or(0); - let old_hl = ts.highlight_file(old_source_path, &old_text); - let new_hl = ts.highlight_file(&file.path, &new_text); - let inline = inline_rows(&display); - let first_inline_hunk_row = inline + self.inline = inline_rows(&self.display); + self.first_inline_hunk_row = self + .inline .iter() .position(is_inline_hunk_content_row) .unwrap_or(0); - let display_hunk = display + self.display_hunk = self + .display .iter() .map(|row| { let (old, new) = display_row_linenos(row); - hunk_for_linenos(&file.hunks, old, new) + hunk_for_linenos(&self.hunks, old, new) }) .collect(); - let inline_hunk = inline + self.inline_hunk = self + .inline .iter() .map(|row| { let (old, new) = inline_row_linenos(row); - hunk_for_linenos(&file.hunks, old, new) + hunk_for_linenos(&self.hunks, old, new) }) .collect(); - Self { - old_text, - new_text, - old_lines, - new_lines, - display, - first_hunk_row, - first_inline_hunk_row, - old_hl, - new_hl, - word_spans: HashMap::new(), - inline, - inline_word_spans: HashMap::new(), - display_hunk, - inline_hunk, - } + self.word_spans.clear(); + self.inline_word_spans.clear(); + } + + /// Accumulate an expansion request for the gap keyed `key` (CS8's progressive reveal) and + /// rebuild the derived rows. `more_before`/`more_after` ADD to whatever was already revealed + /// at that edge (repeated `Enter` presses widen further); `full` is sticky — once set for this + /// gap it stays set. A `key` with no matching gap in the current `display` is harmless: the + /// entry simply sits unused in the map until a gap with that key exists again (it never will, + /// since keys are stable pre-collapse indices — this is just defensive, not reachable from + /// [`App::expand_gap_at_cursor`], which validates the cursor row first). + pub fn expand_gap(&mut self, key: usize, more_before: usize, more_after: usize, full: bool) { + let entry = self.expansions.entry(key).or_default(); + entry.before += more_before; + entry.after += more_after; + entry.full |= full; + self.rebuild_rows(); } /// The hunk (index into the file's `hunks`) whose span covers display row `row`, or `None` @@ -3037,6 +3092,39 @@ impl App { } } + /// Reveal more of the collapsed gap under the cursor (`Enter`), or the WHOLE gap (`E`, when + /// `full`) — CS8's progressive unfold. A silent no-op when the cursor isn't on a `Gap` row (or + /// there's no loaded view): unlike a staging refusal this isn't a mode error worth + /// interrupting the user over, same precedent as [`Self::next_hunk_row`] finding no later + /// hunk. Each edge widens by 10 rows per press; repeated presses on the same gap accumulate + /// (see [`FileView::expand_gap`]). + /// + /// `self.cursor`'s INDEX is left untouched. Rows revealed at the gap's leading edge insert + /// immediately before the gap's own row (shifting the gap marker — and everything after it — + /// down), so after [`FileView::rebuild_rows`] the row now sitting at the old index is the + /// first newly revealed line rather than the gap marker itself: the cursor visually lands on + /// the start of the revealed region without this method needing to compute a new index. + pub fn expand_gap_at_cursor(&mut self, full: bool) { + let cursor = self.cursor; + let layout = self.layout; + let Some(view) = self.current_view() else { + return; + }; + let key = match layout { + Layout::Sbs => match view.display.get(cursor) { + Some(DisplayRow::Gap { key, .. }) => *key, + _ => return, + }, + Layout::Inline => match view.inline.get(cursor) { + Some(InlineRow::Gap { key, .. }) => *key, + _ => return, + }, + }; + view.expand_gap(key, 10, 10, full); + 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 @@ -4707,7 +4795,7 @@ mod tests { } fn gap_row(skipped: usize) -> DisplayRow { - DisplayRow::Gap { skipped } + DisplayRow::Gap { key: 0, skipped } } #[test] @@ -9470,4 +9558,199 @@ mod tests { other => panic!("expected the cursor on b.txt's File row, got {other:?}"), } } + + // ── CS8: progressive gap expansion ────────────────────────────────────── + + /// A single-file fixture with two hunks separated by a wide (40-line) unchanged run — wide + /// enough that even a full 10/10 [`App::expand_gap_at_cursor`] press still leaves a + /// surviving [`DisplayRow::Gap`] (`40 - 2*3 - 2*10 = 14` rows still hidden), unlike + /// [`two_hunk_fixture`]'s much narrower gap. + fn two_hunks_with_a_wide_gap_fixture() -> Fixture { + let mut committed = String::from("OLD_HUNK_A\n"); + let mut modified = String::from("NEW_HUNK_A\n"); + for i in 1..=40 { + committed.push_str(&format!("ctx{i}\n")); + modified.push_str(&format!("ctx{i}\n")); + } + committed.push_str("OLD_HUNK_B\n"); + modified.push_str("NEW_HUNK_B\n"); + + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", &committed, &modified) + .build() + .unwrap() + } + + /// The display-row index of the current file's ONLY gap row — the fixture shape every CS8 + /// expansion test below relies on. + fn only_gap_row(app: &App) -> usize { + app.current_view_ref() + .expect("loaded view") + .display + .iter() + .position(|r| matches!(r, DisplayRow::Gap { .. })) + .expect("expected exactly one gap row") + } + + #[test] + fn expand_gap_at_cursor_on_a_gap_row_reveals_more_rows() { + 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); + let before_len = app.current_view_ref().unwrap().display.len(); + app.cursor = gap_row; + + app.expand_gap_at_cursor(false); + + let view = app.current_view_ref().unwrap(); + assert!( + view.display.len() > before_len, + "expanding must reveal more rows: {before_len} -> {}", + view.display.len() + ); + assert!( + app.cursor < view.display.len(), + "cursor must stay in bounds" + ); + assert!( + matches!(view.display[app.cursor], DisplayRow::Row(_)), + "the cursor's old index (the gap's leading edge) must now hold a revealed row, not \ + the gap marker: {:?}", + view.display[app.cursor] + ); + // The gap is wide enough (40 hidden rows) that a single 10/10 press doesn't consume it. + assert!( + view.display + .iter() + .any(|r| matches!(r, DisplayRow::Gap { .. })), + "a partial expansion of this fixture must still leave a gap row" + ); + } + + #[test] + fn expand_gap_at_cursor_on_a_non_gap_row_is_a_no_op() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); // cursor lands on hunk A's row, not the gap + + let before_len = app.current_view_ref().unwrap().display.len(); + let before_cursor = app.cursor; + assert!( + !matches!( + app.current_view_ref().unwrap().display[before_cursor], + DisplayRow::Gap { .. } + ), + "precondition: cursor starts on hunk A, not the gap" + ); + + app.expand_gap_at_cursor(false); + + assert_eq!(app.cursor, before_cursor, "no-op must not move the cursor"); + assert_eq!( + app.current_view_ref().unwrap().display.len(), + before_len, + "no-op must not change the row count" + ); + assert!(app.notice.is_none(), "a no-op must not raise a notice"); + } + + #[test] + fn stage_hunk_after_expanding_a_gap_stages_the_intended_hunk() { + 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); + + // Move to hunk B (the LATER hunk) through the freshly rebuilt `display`/`display_hunk` — + // this is the coordinate-space desync CS8 must not introduce: `display_hunk` is + // recomputed by `rebuild_rows` from the SAME `aligned`/`hunks` every time, so the row + // under the cursor must still resolve to the right hunk index after an expansion. + app.next_hunk_row(); + app.stage_hunk(); + + assert!(app.notice.is_none(), "stage must succeed: {:?}", app.notice); + let repo = fixture.repo().unwrap(); + let mut expected_index = String::from("OLD_HUNK_A\n"); + let mut expected_workdir = String::from("NEW_HUNK_A\n"); + for i in 1..=40 { + expected_index.push_str(&format!("ctx{i}\n")); + expected_workdir.push_str(&format!("ctx{i}\n")); + } + expected_index.push_str("NEW_HUNK_B\n"); + expected_workdir.push_str("NEW_HUNK_B\n"); + // The index picks up ONLY hunk B; hunk A must stay unstaged. + repo.assert(predicate::repo::index_blob_equals( + "f.txt", + expected_index.as_str(), + )); + repo.assert(predicate::repo::workdir_file_equals( + "f.txt", + expected_workdir.as_str(), + )); + } + + #[test] + fn expanding_a_gap_clears_the_row_keyed_word_span_cache() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); // cursor on hunk A's row — a word-diff pair + + let hunk_a_row = app.cursor; + app.current_view().unwrap().word_spans_for_row(hunk_a_row); + assert!( + !app.current_view_ref().unwrap().word_spans.is_empty(), + "precondition: the cache must be populated before expanding" + ); + + let gap_row = only_gap_row(&app); + app.cursor = gap_row; + app.expand_gap_at_cursor(false); + + assert!( + app.current_view_ref().unwrap().word_spans.is_empty(), + "rebuild_rows must clear the row-keyed word-span cache — a stale entry would \ + mismatch the row it renders under post-expansion" + ); + // The cache is still USABLE post-clear, not just permanently empty — re-populating it + // must not panic and must produce a non-empty span for the still-word-diffable row. + let (old_spans, new_spans) = app.current_view().unwrap().word_spans_for_row(hunk_a_row); + assert!( + !old_spans.is_empty() || !new_spans.is_empty(), + "hunk A is still a word-diff pair after the rebuild" + ); + } + + #[test] + fn refresh_resets_a_files_gap_expansions() { + 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); + let expanded_len = app.current_view_ref().unwrap().display.len(); + + app.refresh(); // ends with its own `open_current`, same as every other refresh path + + let view = app.current_view_ref().expect("view survives refresh"); + assert!( + view.display.len() < expanded_len, + "a fresh view must re-collapse to the base gap window, not carry over the prior \ + expansion: expanded {expanded_len}, post-refresh {}", + view.display.len() + ); + assert!( + view.display + .iter() + .any(|r| matches!(r, DisplayRow::Gap { .. })), + "the gap must be back in its base (still-collapsed) form" + ); + } } diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index d2b823e..616c9eb 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -62,6 +62,8 @@ pub enum Command { PrevHunk, NextChangeset, PrevChangeset, + ExpandGap, + ExpandGapAll, // Diff view. FocusOutline, // Outline view. @@ -273,6 +275,20 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "h left", description: "Focus the outline", }, + Registered { + command: Command::ExpandGap, + view: View::Diff, + name: "expand-gap", + default_keys: "enter", + description: "Reveal more of the collapsed gap under the cursor", + }, + Registered { + command: Command::ExpandGapAll, + view: View::Diff, + name: "expand-gap-all", + default_keys: "E", + description: "Reveal the whole collapsed gap under the cursor", + }, // ── Outline view ───────────────────────────────────────────────────────── Registered { command: Command::OutlineDown, diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 2383c16..8c7dbf5 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -753,9 +753,9 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, them } } -/// Write a gap row's `··· N unchanged lines ···` 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. +/// 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. fn render_gap_row( buf: &mut Buffer, area: Rect, @@ -765,7 +765,7 @@ fn render_gap_row( is_selected: bool, theme: &Palette, ) { - let msg = format!("··· {skipped} unchanged lines ···"); + 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 { @@ -1253,7 +1253,7 @@ fn render_pane_sbs( let is_cursor = cursor == Some(row_idx); let is_selected = selection.is_some_and(|(lo, hi)| row_idx >= lo && row_idx <= hi); match &view.display[row_idx] { - DisplayRow::Gap { skipped } => { + DisplayRow::Gap { skipped, .. } => { render_gap_row( frame.buffer_mut(), area, @@ -1456,7 +1456,7 @@ fn render_pane_inline( let is_cursor = cursor == Some(row_idx); let is_selected = selection.is_some_and(|(lo, hi)| row_idx >= lo && row_idx <= hi); match &view.inline[row_idx] { - InlineRow::Gap { skipped } => { + InlineRow::Gap { skipped, .. } => { render_gap_row( frame.buffer_mut(), area, diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index d764453..822d213 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -413,6 +413,8 @@ enum Action { DiscardHunk, DiscardFile, StartSelection, + ExpandGap, + ExpandGapAll, ToggleOutline, OutlineMoveBy(i64), OutlineConfirm, @@ -451,6 +453,8 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::DiscardHunk => Action::DiscardHunk, Command::DiscardFile => Action::DiscardFile, Command::StartSelection => Action::StartSelection, + Command::ExpandGap => Action::ExpandGap, + Command::ExpandGapAll => Action::ExpandGapAll, Command::NextFile => Action::NextFile, Command::PrevFile => Action::PrevFile, Command::NextHunk => Action::NextHunk, @@ -539,6 +543,8 @@ fn action_needs_loaded_view(action: Action) -> bool { | Action::DiscardFile | Action::StartSelection | Action::ToggleSplitFocus + | Action::ExpandGap + | Action::ExpandGapAll ) } @@ -574,6 +580,8 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::DiscardHunk => app.discard_hunk(), Action::DiscardFile => app.discard_file(), Action::StartSelection => app.start_selection(), + Action::ExpandGap => app.expand_gap_at_cursor(false), + Action::ExpandGapAll => app.expand_gap_at_cursor(true), Action::ToggleOutline => app.toggle_outline(), Action::OutlineMoveBy(delta) => app.outline_move_by(delta), Action::OutlineConfirm => app.outline_confirm(), @@ -1340,6 +1348,37 @@ mod tests { ); } + #[test] + fn enter_and_shift_e_map_to_expand_gap_in_diff_context() { + // CS8: `enter`/`E` are bound in View::Diff only (`expand-gap`/`expand-gap-all`) — Enter + // stays `OutlineConfirm` when the outline has focus (see the next test). + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + assert_eq!( + map_key(&km, &mut pending, key(KeyCode::Enter), 20, false, false), + Action::ExpandGap + ); + assert_eq!( + map_key(&km, &mut pending, key(KeyCode::Char('E')), 20, false, false), + Action::ExpandGapAll + ); + // Still diff-scoped even with the outline open, as long as it isn't focused. + assert_eq!( + map_key(&km, &mut pending, key(KeyCode::Enter), 20, false, true), + Action::ExpandGap + ); + } + + #[test] + fn enter_still_maps_to_outline_confirm_when_outline_focused() { + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + assert_eq!( + map_key(&km, &mut pending, key(KeyCode::Enter), 20, true, true), + Action::OutlineConfirm + ); + } + #[test] fn shift_l_maps_to_toggle_layout() { let km = Keymap::defaults(); From 545a3f79db8803b2df05d6d2900adb761856ab32 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 23:22:15 -0400 Subject: [PATCH 117/203] fix(review): cancel the line selection when a gap expansion reshapes rows --- git-workon-review/src/app.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index bd26031..3c802be 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -3121,6 +3121,11 @@ impl App { }, }; view.expand_gap(key, 10, 10, full); + // The expansion just reshaped the focused pane's row space — cancel any active selection + // rather than translating it, per `selection_anchor`'s invariant (same rule as layout + // toggles, zoom changes, file switches, and split-focus swaps). Only reached when a gap + // actually expanded; the non-gap no-op above leaves a selection alone. + self.cancel_selection(); self.derive_scroll(); self.clamp_cursor(); } @@ -9593,6 +9598,32 @@ mod tests { .expect("expected exactly one gap row") } + #[test] + fn expand_gap_cancels_an_active_selection_but_a_non_gap_press_leaves_it_alone() { + // An expansion reshapes the focused pane's row space, so `selection_anchor`'s invariant + // (cancel, never translate) applies — a selection made before the expand would silently + // cover different lines after it. The non-gap no-op path must NOT cancel: nothing + // reshaped. + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + app.start_selection(); + assert!(app.selection_anchor.is_some(), "selection must start"); + app.expand_gap_at_cursor(false); // cursor sits on the first hunk, not a gap: no-op + assert!( + app.selection_anchor.is_some(), + "a no-op press on a non-gap row must leave the selection alone" + ); + + app.cursor = only_gap_row(&app); + app.expand_gap_at_cursor(false); + assert!( + app.selection_anchor.is_none(), + "an actual expansion reshapes the row space and must cancel the selection" + ); + } + #[test] fn expand_gap_at_cursor_on_a_gap_row_reveals_more_rows() { let fixture = two_hunks_with_a_wide_gap_fixture(); From 60783e313482467b816dd82ad080218de64f2ef9 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 11 Jul 2026 10:15:58 -0400 Subject: [PATCH 118/203] fix(review): gate test-only collapse_gaps_with behind cfg(test) --- git-workon-review/src/align.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/git-workon-review/src/align.rs b/git-workon-review/src/align.rs index 6e26159..3243b1c 100644 --- a/git-workon-review/src/align.rs +++ b/git-workon-review/src/align.rs @@ -252,6 +252,7 @@ pub fn collapse_gaps_with_expansions( } /// Same as [`collapse_gaps_with_expansions`] but with an explicit context-line count, for testing. +#[cfg(test)] fn collapse_gaps_with(rows: &[AlignedRow], context: usize) -> Vec { collapse_gaps_inner(rows, context, &HashMap::new()) } From becd630e502ade6af55a34ec9b8b8d92da8d13af Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 21:26:40 -0400 Subject: [PATCH 119/203] feat(review): reveal gaps to the enclosing tree-sitter scope --- git-workon-review/src/align.rs | 53 +++++ git-workon-review/src/app.rs | 311 +++++++++++++++++++++++++++-- git-workon-review/src/highlight.rs | 25 ++- git-workon-review/src/lib.rs | 1 + git-workon-review/src/scope.rs | 181 +++++++++++++++++ 5 files changed, 552 insertions(+), 19 deletions(-) create mode 100644 git-workon-review/src/scope.rs diff --git a/git-workon-review/src/align.rs b/git-workon-review/src/align.rs index 3243b1c..f57f625 100644 --- a/git-workon-review/src/align.rs +++ b/git-workon-review/src/align.rs @@ -333,6 +333,59 @@ fn collapse_gaps_inner( out } +/// The currently-hidden [`AlignedRow`] sub-range `[start, end)` for the gap keyed `key`, given +/// its current `expansion` (if any) — used by [`crate::app::FileView::scope_expand_gap`] (CS9) to +/// measure how much of a gap's hidden run a candidate tree-sitter scope range would additionally +/// uncover. `None` when `key` no longer denotes an actual gap: not a context-run start, the run is +/// too short to have collapsed in the first place, or `expansion` already reveals the whole run. +/// +/// Mirrors the run-measuring steps in [`collapse_gaps_inner`] (same `keep_before`/`keep_after`/ +/// `effective_before`/`effective_after` derivation) rather than sharing code with it, because that +/// function additionally needs `run_end` and the row slices themselves to emit `DisplayRow`s, +/// while this one only needs the hidden index range for a `key` a caller already has — keep both +/// in sync if the collapse rule ever changes. +pub(crate) fn gap_hidden_range( + rows: &[AlignedRow], + key: usize, + expansions: &HashMap, +) -> Option<(usize, usize)> { + let is_context = |row: &AlignedRow| { + matches!( + (row.old_kind, row.new_kind), + (CellKind::Context, CellKind::Context) + ) + }; + + let run_start = key; + if run_start >= rows.len() || !is_context(&rows[run_start]) { + return None; + } + let mut run_end = run_start; + while run_end < rows.len() && is_context(&rows[run_end]) { + run_end += 1; + } + let run_len = run_end - run_start; + + let keep_before = if run_start == 0 { 0 } else { CONTEXT_LINES }; + let keep_after = if run_end == rows.len() { + 0 + } else { + CONTEXT_LINES + }; + if (keep_before == 0 && keep_after == 0) || run_len <= keep_before + keep_after { + return None; + } + + let expansion = expansions.get(&key).copied().unwrap_or_default(); + let effective_before = (keep_before + expansion.before).min(run_len); + let effective_after = (keep_after + expansion.after).min(run_len - effective_before); + if expansion.full || effective_before + effective_after >= run_len { + return None; + } + + Some((run_start + effective_before, run_end - effective_after)) +} + /// One row of the inline (unified, single-column) display. /// /// Built by [`inline_rows`] from the SAME gap-collapsed [`DisplayRow`] vector [`collapse_gaps`] diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 3c802be..46a5ce7 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -17,18 +17,19 @@ use workon::{Changeset, ChangesetSpan}; use crate::acquire::{ChangesetDiff, WorktreeDiffs}; use crate::align::{ - align_file, collapse_gaps_with_expansions, inline_rows, AlignedRow, CellKind, DisplayRow, - GapExpansion, InlineRow, Row, + align_file, collapse_gaps_with_expansions, gap_hidden_range, inline_rows, AlignedRow, CellKind, + DisplayRow, GapExpansion, InlineRow, Row, }; use crate::apply::{Git2Applier, StageVerb}; use crate::config::RawViewConfig; -use crate::highlight::{FgSpan, TsHighlighter}; +use crate::highlight::{lang_key_for_ext, FgSpan, TsHighlighter}; use crate::icons::OutlineIcons; use crate::model::{DiffModel, FileChange, FileStatus, Hunk, LineKind}; use crate::ops; use crate::outline::{self, OutlineChangeset, OutlineFile, OutlineItem, OutlineMode, OutlineOrder}; use crate::queue::{OpOutcome, StagingOp, StagingQueue}; use crate::refresh::{IndexSignature, RefreshCoordinator}; +use crate::scope::enclosing_scope_lines; use crate::source::{resolve_source, Source}; use crate::stage_op::{FileStagingOp, LineSelectionOp}; use crate::summary; @@ -244,6 +245,59 @@ impl FileView { self.rebuild_rows(); } + /// 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 + /// `anchor_prefers_new`'s frame (new-side lineno when `true`, old-side when `false` — see + /// [`App::expand_gap_at_cursor`]'s anchor selection). Only the gap's TRAILING edge (`after`) + /// is ever widened: the anchor sits at the gap's following edge and `scope_start` is what + /// climbs upward from it toward the gap; `scope_end` falls among rows already visible after + /// the gap by construction (the anchor line is inside the scope), so the leading edge never + /// has anything new to reveal here. + /// + /// Returns `true` when this widened the gap (grew `after`, or revealed the whole run because + /// the scope covers it entirely); `false` when the scope added nothing new — either the gap + /// is already fully revealed/not a gap at all, or `scope_start` doesn't reach far enough + /// upward to uncover any currently-hidden row. The caller's signal to fall back to the flat + /// +10 reveal, so repeated presses always widen. + pub fn scope_expand_gap( + &mut self, + key: usize, + scope_start: usize, + anchor_prefers_new: bool, + ) -> bool { + let Some((hidden_start, hidden_end)) = + gap_hidden_range(&self.aligned, key, &self.expansions) + else { + return false; + }; + let hidden = &self.aligned[hidden_start..hidden_end]; + if hidden.is_empty() { + return false; + } + + let lineno_of = + |row: &AlignedRow| row_lineno(if anchor_prefers_new { row.new } else { row.old }); + // Context rows always carry a lineno on both sides (see the module doc's lineno + // invariant), and linenos increase monotonically through a run, so counting from the + // trailing edge backward while the scope still covers each row is safe. + let count = hidden + .iter() + .rev() + .take_while(|row| lineno_of(row).is_some_and(|n| n >= scope_start)) + .count(); + + if count == 0 { + return false; + } + if count >= hidden.len() { + self.expand_gap(key, 0, 0, true); + } else { + self.expand_gap(key, 0, count, false); + } + true + } + /// The hunk (index into the file's `hunks`) whose span covers display row `row`, or `None` /// for a row outside every hunk (a gap, or context beyond any `@@` block). A context line git /// kept inside a hunk's header counts as "in" that hunk (matching the prototype's `hunk_at`). @@ -3093,20 +3147,40 @@ impl App { } /// Reveal more of the collapsed gap under the cursor (`Enter`), or the WHOLE gap (`E`, when - /// `full`) — CS8's progressive unfold. A silent no-op when the cursor isn't on a `Gap` row (or - /// there's no loaded view): unlike a staging refusal this isn't a mode error worth - /// interrupting the user over, same precedent as [`Self::next_hunk_row`] finding no later - /// hunk. Each edge widens by 10 rows per press; repeated presses on the same gap accumulate - /// (see [`FileView::expand_gap`]). + /// `full`) — CS8's progressive unfold, extended by CS9 with a two-tier `Enter`: A silent + /// no-op when the cursor isn't on a `Gap` row (or there's no loaded view): unlike a staging + /// refusal this isn't a mode error worth interrupting the user over, same precedent as + /// [`Self::next_hunk_row`] finding no later hunk. + /// + /// - `full` (`E`): unchanged from CS8 — always the flat full-run reveal via + /// [`FileView::expand_gap`], regardless of grammar. + /// - `!full` (`Enter`, CS9): FIRST tries a tree-sitter scope-reveal — + /// [`gap_scope_start`] resolves the gap's anchor (the following row's new-side lineno, + /// preferring new like CS6's [`Self::restore_position`], old-side for delete-only files) + /// to the smallest enclosing [`crate::scope`] node, and [`FileView::scope_expand_gap`] + /// widens the gap's trailing edge to uncover it. Falls back to the flat +10/+10 reveal + /// (same as CS8) when: the file's extension has no bundled grammar, no allowlisted + /// ancestor encloses the anchor, or the scope reveals nothing new (already fully visible) + /// — so repeated `Enter` presses always widen the gap, uniformly. /// - /// `self.cursor`'s INDEX is left untouched. Rows revealed at the gap's leading edge insert - /// immediately before the gap's own row (shifting the gap marker — and everything after it — - /// down), so after [`FileView::rebuild_rows`] the row now sitting at the old index is the - /// first newly revealed line rather than the gap marker itself: the cursor visually lands on - /// the start of the revealed region without this method needing to compute a new index. + /// `self.cursor`'s INDEX is left untouched either way. Rows revealed at the gap's leading + /// edge insert immediately before the gap's own row (shifting the gap marker — and + /// everything after it — down), so after [`FileView::rebuild_rows`] the row now sitting at + /// the old index is the first newly revealed line rather than the gap marker itself: the + /// cursor visually lands on the start of the revealed region without this method needing to + /// compute a new index. The scope-reveal path only ever widens the TRAILING edge (see + /// [`FileView::scope_expand_gap`]'s doc for why), so this holds there too. pub fn expand_gap_at_cursor(&mut self, full: bool) { let cursor = self.cursor; let layout = self.layout; + // Read out before taking `current_view()`'s exclusive borrow — `gap_scope_start` only + // needs the path strings, not the file, so cloning two short `String`s here avoids a + // `self.cur()`/`self.current_view()` borrow conflict for the whole rest of the method. + let anchor_paths = self.cur().diff.files.get(self.current).map(|f| { + let new_path = f.path.clone(); + let old_path = f.old_path.clone().unwrap_or_else(|| f.path.clone()); + (new_path, old_path) + }); let Some(view) = self.current_view() else { return; }; @@ -3120,11 +3194,25 @@ impl App { _ => return, }, }; - view.expand_gap(key, 10, 10, full); - // The expansion just reshaped the focused pane's row space — cancel any active selection - // rather than translating it, per `selection_anchor`'s invariant (same rule as layout - // toggles, zoom changes, file switches, and split-focus swaps). Only reached when a gap - // actually expanded; the non-gap no-op above leaves a selection alone. + + let scope_revealed = !full + && anchor_paths + .as_ref() + .and_then(|(new_path, old_path)| { + gap_scope_start(view, layout, cursor, new_path, old_path) + }) + .is_some_and(|(scope_start, anchor_prefers_new)| { + view.scope_expand_gap(key, scope_start, anchor_prefers_new) + }); + + if !scope_revealed { + view.expand_gap(key, 10, 10, full); + } + // The expansion just reshaped the focused pane's row space — whichever tier did it — + // so cancel any active selection rather than translating it, per `selection_anchor`'s + // invariant (same rule as layout toggles, zoom changes, file switches, and split-focus + // swaps). Only reached when a gap actually expanded; the non-gap no-op above leaves a + // selection alone. self.cancel_selection(); self.derive_scroll(); self.clamp_cursor(); @@ -4167,6 +4255,43 @@ fn display_row_linenos(row: &DisplayRow) -> (Option, Option) { } } +/// CS9's tree-sitter scope-reveal inputs for the gap at `gap_cursor`: the anchor line and which +/// side it's in (`true` = new, `false` = old), resolved from the row immediately FOLLOWING the +/// gap in `layout`'s row vector — the plan's rationale: the next hunk is what you're reading +/// toward, so its enclosing scope is what's worth revealing. Prefers the new-side lineno when +/// present, falling back to old (CS6's [`App::restore_position`] convention) for the rows a +/// delete-only file's `Filler` new side never populates. +/// +/// Returns `None` when: there's no row after the gap (a trailing gap with nothing beyond it to +/// anchor on), the anchor path's extension has no bundled grammar, or +/// [`enclosing_scope_lines`] finds no enclosing scope for the anchor line — every case the +/// caller treats identically, falling back to the flat +10/+10 reveal. +fn gap_scope_start( + view: &FileView, + layout: Layout, + gap_cursor: usize, + new_path: &str, + old_path: &str, +) -> Option<(usize, bool)> { + let (old, new) = match layout { + Layout::Sbs => display_row_linenos(view.display.get(gap_cursor + 1)?), + Layout::Inline => inline_row_linenos(view.inline.get(gap_cursor + 1)?), + }; + + let (anchor_line, anchor_prefers_new, text, lang_path) = match new { + Some(n) => (n, true, view.new_text(), new_path), + None => (old?, false, view.old_text(), old_path), + }; + + let ext = Path::new(lang_path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + let lang_key = lang_key_for_ext(ext)?; + let (scope_start, _scope_end) = enclosing_scope_lines(lang_key, text, anchor_line)?; + Some((scope_start, anchor_prefers_new)) +} + /// Inline-coordinate analog of [`display_row_linenos`]. fn inline_row_linenos(row: &InlineRow) -> (Option, Option) { match *row { @@ -9784,4 +9909,154 @@ mod tests { "the gap must be back in its base (still-collapsed) form" ); } + + // ── 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 + /// unchanged run between them wide enough that even a +10/+10 press would still leave a + /// gap (mirrors [`two_hunks_with_a_wide_gap_fixture`]'s width) — but because the whole + /// hidden run lies inside `long_function`'s body, a scope-reveal press should uncover it + /// ENTIRELY (the function encloses the whole gap), unlike +10/+10. + fn function_with_a_wide_internal_gap_fixture() -> Fixture { + let mut committed = String::from("fn long_function() {\n let a = OLD_A;\n"); + let mut modified = String::from("fn long_function() {\n let a = NEW_A;\n"); + for i in 1..=40 { + committed.push_str(&format!(" ctx{i}();\n")); + modified.push_str(&format!(" ctx{i}();\n")); + } + committed.push_str(" let b = OLD_B;\n}\n"); + modified.push_str(" let b = NEW_B;\n}\n"); + + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.rs", &committed, &modified) + .build() + .unwrap() + } + + /// A `.rs` fixture where both edits sit at the TOP LEVEL (no enclosing function/impl/etc — + /// only comment lines separate them), so [`crate::scope::enclosing_scope_lines`] finds no + /// allowlisted ancestor around the anchor and a press must fall back to +10/+10 exactly like + /// a grammar-less file. + fn top_level_edits_with_a_wide_gap_fixture() -> Fixture { + let mut committed = String::from("static A: i32 = OLD_A;\n"); + let mut modified = String::from("static A: i32 = NEW_A;\n"); + for i in 1..=40 { + committed.push_str(&format!("// ctx{i}\n")); + modified.push_str(&format!("// ctx{i}\n")); + } + committed.push_str("static B: i32 = OLD_B;\n"); + modified.push_str("static B: i32 = NEW_B;\n"); + + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.rs", &committed, &modified) + .build() + .unwrap() + } + + /// The `skipped` count of the current file's only [`DisplayRow::Gap`], found by scanning + /// `display` (NOT via `app.cursor` — expanding the gap's leading edge shifts the gap marker + /// to a later index, same as [`only_gap_row`] re-finds it after an expansion in the CS8 + /// tests above). Panics if there isn't exactly one gap row. + fn gap_skipped(app: &App) -> usize { + let row = only_gap_row(app); + match app.current_view_ref().expect("loaded view").display[row] { + DisplayRow::Gap { skipped, .. } => skipped, + other => panic!("expected a Gap row, got {other:?}"), + } + } + + #[test] + fn scope_reveal_uncovers_the_whole_gap_when_the_enclosing_function_covers_it() { + let fixture = function_with_a_wide_internal_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); + + let view = app.current_view_ref().unwrap(); + assert!( + !view + .display + .iter() + .any(|r| matches!(r, DisplayRow::Gap { .. })), + "the enclosing function covers the ENTIRE hidden run, so a single scope-reveal press \ + must consume the gap completely — unlike a flat +10/+10 press, which would still \ + leave one on this fixture's 40-row gap: {:?}", + view.display + ); + } + + #[test] + fn a_grammarless_file_falls_back_to_the_flat_plus_ten_reveal() { + // Reuse CS8's `.txt` fixture (no bundled grammar for that extension) — the scope-reveal + // path must find no lang key and fall straight through to +10/+10, same as before CS9. + 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; + let skipped_before = gap_skipped(&app); + + app.expand_gap_at_cursor(false); + + let skipped_after = gap_skipped(&app); + assert_eq!( + skipped_before - skipped_after, + 20, + "no grammar for .txt: exactly the flat 10-before/10-after reveal, same as CS8" + ); + } + + #[test] + fn a_scope_with_nothing_new_falls_back_to_the_flat_plus_ten_reveal() { + // Both edits are top-level `static`s with no enclosing function/impl/etc — the anchor + // line has no allowlisted ancestor, so scope-reveal finds nothing and must fall back to + // +10/+10 exactly like the grammarless case, even though this file DOES have a grammar. + let fixture = top_level_edits_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; + let skipped_before = gap_skipped(&app); + + app.expand_gap_at_cursor(false); + + let skipped_after = gap_skipped(&app); + assert_eq!( + skipped_before - skipped_after, + 20, + "no enclosing scope at the top level: falls back to the flat 10-before/10-after reveal" + ); + } + + #[test] + fn full_expand_ignores_scope_reveal_regardless_of_grammar() { + // `E` (full=true) must stay pure CS8 behavior even on a file with a grammar and a scope + // that would otherwise apply — scope-reveal is an `Enter`-only (CS9) refinement. + let fixture = function_with_a_wide_internal_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(true); + + let view = app.current_view_ref().unwrap(); + assert!( + !view + .display + .iter() + .any(|r| matches!(r, DisplayRow::Gap { .. })), + "E must fully expand the gap: {:?}", + view.display + ); + } } diff --git a/git-workon-review/src/highlight.rs b/git-workon-review/src/highlight.rs index edbd4bb..dffb9a8 100644 --- a/git-workon-review/src/highlight.rs +++ b/git-workon-review/src/highlight.rs @@ -67,7 +67,10 @@ pub fn capture_index(name: &str) -> Option { HIGHLIGHT_NAMES.iter().position(|n| *n == name) } -fn lang_key_for_ext(ext: &str) -> Option<&'static str> { +/// Maps a file extension to the [`build_config`]/[`language_for_key`] key for its grammar, or +/// `None` when no bundled grammar covers it. `pub(crate)` so [`crate::app`] can resolve a gap's +/// anchor file to a scope-lookup language (CS9) without duplicating this table. +pub(crate) fn lang_key_for_ext(ext: &str) -> Option<&'static str> { match ext { "rs" => Some("rust"), "lua" => Some("lua"), @@ -81,6 +84,26 @@ fn lang_key_for_ext(ext: &str) -> Option<&'static str> { } } +/// The raw `tree_sitter::Language` for a [`lang_key_for_ext`] key, with no highlight query +/// configuration attached — [`build_config`] below wraps the same grammar constructors together +/// with a language's highlight/injection/locals queries for `TsHighlighter`; [`crate::scope`] +/// needs only the grammar (it parses to walk node kinds, not to highlight), so it shares this +/// smaller constructor instead of duplicating the `LANGUAGE.into()` calls. +pub(crate) fn language_for_key(key: &str) -> Option { + let language = match key { + "rust" => tree_sitter_rust::LANGUAGE.into(), + "lua" => tree_sitter_lua::LANGUAGE.into(), + "json" => tree_sitter_json::LANGUAGE.into(), + "toml" => tree_sitter_toml_ng::LANGUAGE.into(), + "javascript" => tree_sitter_javascript::LANGUAGE.into(), + "typescript" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + "tsx" => tree_sitter_typescript::LANGUAGE_TSX.into(), + "markdown" => tree_sitter_md::LANGUAGE.into(), + _ => return None, + }; + Some(language) +} + fn build_config(key: &'static str) -> Option { let result = match key { "rust" => HighlightConfiguration::new( diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 33a0363..48851b3 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -30,6 +30,7 @@ pub mod probe_cache; pub mod queue; pub mod refresh; pub mod render; +pub mod scope; pub mod source; pub mod stage_op; pub mod summary; diff --git a/git-workon-review/src/scope.rs b/git-workon-review/src/scope.rs new file mode 100644 index 0000000..6e01076 --- /dev/null +++ b/git-workon-review/src/scope.rs @@ -0,0 +1,181 @@ +//! Enclosing tree-sitter "scope" lookup for CS9's reveal-to-scope gap expansion. +//! +//! Pure module: given a language key (the same key +//! [`crate::highlight::lang_key_for_ext`] resolves a file extension to) and a file's full text, +//! [`enclosing_scope_lines`] finds the smallest allowlisted structural node (function/impl/ +//! class/...) containing a given line, so [`crate::app::App::expand_gap_at_cursor`] can reveal +//! exactly that much of a collapsed gap instead of a flat +10 rows. +//! +//! ## Allowlist philosophy +//! +//! Only "scope" node KINDS are allowlisted per language — deliberately narrow (a function body, +//! an impl/class block, ...) so a press reads as "show me the surrounding definition," not "show +//! me every nested block/expression the cursor happens to sit inside." Languages without a +//! reasonable definition of "scope" for this purpose (json/toml — data, not code with nested +//! definitions) get an empty allowlist, which makes [`enclosing_scope_lines`] always return +//! `None`: the caller falls back to the flat reveal uniformly, no special-casing needed at the +//! call site. +//! +//! ## Line/coordinate conventions +//! +//! The public API is 1-based, matching [`crate::align::Row::Line`] and the rest of the +//! diff-alignment code. tree-sitter's own [`Point::row`] is 0-based; this module converts at its +//! boundary (in, and back out) and nowhere else. The returned range is inclusive on both ends. +//! +//! ## No caching +//! +//! Parsing happens on demand, once per `Enter` press on a gap — bounded by [`MAX_SCOPE_LINES`] +//! (mirrors [`crate::highlight::MAX_HIGHLIGHT_LINES`]'s cap philosophy). That's cheap enough not +//! to be worth a parse-tree cache keyed on file identity + edit generation. + +use tree_sitter::{Parser, Point}; + +use crate::highlight::language_for_key; + +/// Files with more lines than this skip scope lookup entirely (the caller falls back to the flat +/// +N reveal) — same cap philosophy as [`crate::highlight::MAX_HIGHLIGHT_LINES`]. +pub const MAX_SCOPE_LINES: usize = 20_000; + +/// Per-language allowlist of "scope" node kinds, matched by exact string against +/// [`tree_sitter::Node::kind`]. Node kind names are grammar-specific facts verified against the +/// bundled grammars by this module's tests — do not extend without a test parsing a real snippet. +fn scope_kinds(lang_key: &str) -> &'static [&'static str] { + match lang_key { + "rust" => &[ + "function_item", + "impl_item", + "trait_item", + "mod_item", + "struct_item", + "enum_item", + ], + "javascript" => &[ + "function_declaration", + "function_expression", + "method_definition", + "class_declaration", + "arrow_function", + ], + "typescript" | "tsx" => &[ + "function_declaration", + "function_expression", + "method_definition", + "class_declaration", + "arrow_function", + "interface_declaration", + "enum_declaration", + "module_declaration", + ], + "lua" => &["function_declaration", "function_definition"], + // json/toml/markdown: no structural "scope" concept worth revealing to — always fall + // back to the flat reveal. + _ => &[], + } +} + +/// The smallest allowlisted ancestor node (see [`scope_kinds`]) enclosing 1-based `line`, as an +/// inclusive 1-based `(start_line, end_line)` range — or `None` when: `lang_key` has no (or an +/// empty) allowlist, `text` exceeds [`MAX_SCOPE_LINES`], the grammar fails to build, or no +/// allowlisted ancestor contains `line` (e.g. a top-level `use` statement outside any item). +pub fn enclosing_scope_lines(lang_key: &str, text: &str, line: usize) -> Option<(usize, usize)> { + let kinds = scope_kinds(lang_key); + if kinds.is_empty() { + return None; + } + if text.lines().count() > MAX_SCOPE_LINES { + return None; + } + + let language = language_for_key(lang_key)?; + let mut parser = Parser::new(); + parser.set_language(&language).ok()?; + let tree = parser.parse(text, None)?; + + let point = Point { + row: line.saturating_sub(1), + column: 0, + }; + let mut node = tree + .root_node() + .named_descendant_for_point_range(point, point)?; + loop { + if kinds.contains(&node.kind()) { + return Some((node.start_position().row + 1, node.end_position().row + 1)); + } + node = node.parent()?; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rust_line_inside_a_function_body_returns_the_function_range() { + let src = "fn outer() {\n let x = 1;\n let y = 2;\n}\n"; + // Line 2 (`let x = 1;`) is inside `fn outer`, which spans lines 1-4. + let range = enclosing_scope_lines("rust", src, 2); + assert_eq!(range, Some((1, 4))); + } + + #[test] + fn rust_nested_function_returns_the_inner_function_not_the_outer() { + let src = "fn outer() {\n fn inner() {\n let z = 1;\n }\n}\n"; + // Line 3 is inside `inner`, which spans lines 2-4 — the smallest (deepest) allowlisted + // ancestor, not `outer` (lines 1-5). + let range = enclosing_scope_lines("rust", src, 3); + assert_eq!(range, Some((2, 4))); + } + + #[test] + fn rust_top_level_use_line_returns_none() { + let src = "use std::fmt;\n\nfn main() {}\n"; + // Line 1 is a top-level `use` — no allowlisted ancestor contains it. + assert_eq!(enclosing_scope_lines("rust", src, 1), None); + } + + #[test] + fn rust_impl_block_with_two_functions_asking_between_them_returns_the_impl() { + let src = "struct S;\n\nimpl S {\n fn a(&self) {\n let _ = 1;\n }\n\n fn b(&self) {\n let _ = 2;\n }\n}\n"; + // Line 5 is inside `fn a`'s body — smallest allowlisted ancestor is the fn. + assert_eq!(enclosing_scope_lines("rust", src, 5), Some((4, 6))); + // Line 7 is the blank line between the two fns, still inside the impl block but outside + // both fn bodies — smallest allowlisted ancestor is the impl. + assert_eq!(enclosing_scope_lines("rust", src, 7), Some((3, 11))); + } + + #[test] + fn typescript_line_in_a_method_returns_the_method_range() { + let src = "class C {\n method() {\n const x = 1;\n }\n}\n"; + let range = enclosing_scope_lines("typescript", src, 3); + assert_eq!(range, Some((2, 4))); + } + + #[test] + fn typescript_line_in_an_interface_returns_the_interface_range() { + let src = "interface Foo {\n bar: string;\n baz: number;\n}\n"; + let range = enclosing_scope_lines("typescript", src, 2); + assert_eq!(range, Some((1, 4))); + } + + #[test] + fn lua_line_in_a_function_returns_its_range() { + let src = "function greet()\n local msg = \"hi\"\n print(msg)\nend\n"; + let range = enclosing_scope_lines("lua", src, 2); + assert_eq!(range, Some((1, 4))); + } + + #[test] + fn json_always_returns_none() { + let src = "{\n \"a\": 1,\n \"b\": {\n \"c\": 2\n }\n}\n"; + assert_eq!(enclosing_scope_lines("json", src, 4), None); + } + + #[test] + fn oversized_text_returns_none() { + // Synthesize a cheap file with more lines than MAX_SCOPE_LINES; content doesn't matter, + // only line count. + let src = "fn f() {}\n".repeat(MAX_SCOPE_LINES + 1); + assert_eq!(enclosing_scope_lines("rust", &src, 1), None); + } +} From 4d1eb11a7c126494854bf0373bba31b8407923e1 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 23:25:25 -0400 Subject: [PATCH 120/203] fix(review): drop unreachable empty-range guard in scope reveal --- git-workon-review/src/app.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 46a5ce7..9532802 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -271,10 +271,10 @@ impl FileView { else { return false; }; + // Non-empty by construction: `gap_hidden_range` returns `None` (never an empty range) + // once an expansion covers the whole run — see its `effective_before + effective_after + // >= run_len` arm. let hidden = &self.aligned[hidden_start..hidden_end]; - if hidden.is_empty() { - return false; - } let lineno_of = |row: &AlignedRow| row_lineno(if anchor_prefers_new { row.new } else { row.old }); From c081e87bbce8cd18cf0f2245decf1cc7095d3caa Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 11 Jul 2026 14:58:15 -0400 Subject: [PATCH 121/203] feat(review): devicons-backed icon table with brand colors --- Cargo.lock | 10 ++ Cargo.toml | 1 + git-workon-review/Cargo.toml | 1 + git-workon-review/src/icons.rs | 177 +++++++++++++++++++++++--------- git-workon-review/src/render.rs | 30 ++++-- 5 files changed, 162 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bcf1f3a..6048ebe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,6 +563,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "devicons" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e47e2f330cf4fdd5a958dcef921b9523ffc21ab6713aa5e77ba2cce03904b" +dependencies = [ + "lazy_static", +] + [[package]] name = "dialoguer" version = "0.12.0" @@ -964,6 +973,7 @@ dependencies = [ "clap", "clap_complete", "crossterm", + "devicons", "dirs", "expectrl", "git-workon-fixture", diff --git a/Cargo.toml b/Cargo.toml index 34e4346..979ee21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ clap-verbosity-flag = "3.0.4" clap_complete = { version = "4.6.5", features = ["unstable-dynamic"] } clap_mangen = "0.3.0" crossterm = "0.29.0" +devicons = "0.6" dialoguer = { version = "0.12.0", features = ["fuzzy-select"] } dirs = "6.0" env_logger = "0.11.10" diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index 32bc7e9..d9e9a40 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -35,6 +35,7 @@ vendored = ["git-workon-lib/vendored", "git2/vendored-libgit2", "git2/vendored-o clap.workspace = true clap_complete.workspace = true crossterm.workspace = true +devicons.workspace = true dirs.workspace = true git-workon-lib.workspace = true git2.workspace = true diff --git a/git-workon-review/src/icons.rs b/git-workon-review/src/icons.rs index cd3ba7d..dc8f969 100644 --- a/git-workon-review/src/icons.rs +++ b/git-workon-review/src/icons.rs @@ -6,6 +6,21 @@ //! icons are strictly opt-in via `workon.review.outline.icons = nerd` (see `config.rs`'s schema //! doc block and `App::apply_view_config`). With the config left at its default (`none`), //! nothing in this module is ever called from `render.rs`. +//! +//! **Icon table (CS1 polish pass):** per-file glyphs and brand colors are looked up via the +//! [`devicons`] crate (Apache-2.0, `alexpasmantier/devicons`) rather than a hand-rolled table — +//! 597 filename+extension entries with the same filename-before-extension precedence +//! [`icon_for_path`] already followed. devicons ships separate Dark/Light color maps; the caller +//! picks one from the active [`crate::theme::Palette`] (see [`icon_for_path`]'s doc comment). +//! **Nerd-font v3 requirement:** devicons' glyphs are drawn from nerd-font v3's private-use +//! codepoints, roughly a fifth of which sit in a Unicode supplementary plane (outside the BMP). +//! The crate's own `OutlineIcons::Nerd` glyphs picked in CS3 (status/header markers) stay +//! BMP-only for wider font compatibility, but a per-file icon from devicons may require a v3 +//! nerd-font — this is the same "no auto-detection" opt-in tradeoff as the rest of this module. +//! devicons does not cover directories (it is a per-file mapper), so [`DIR_ICON`] is still ours. + +use devicons::{icon_for_file, FileIcon, Theme as DeviconsTheme}; +use ratatui::style::Color; /// Which of the outline's icon strategies is active — `workon.review.outline.icons` /// (`nerd`/`none`), read once at startup by `App::apply_view_config` (CS5 mirrors CS3's @@ -22,40 +37,59 @@ pub enum OutlineIcons { } /// The directory-row icon (nerd-font `nf-fa-folder`, U+F07B) — used for every -/// [`crate::outline::OutlineItem::Dir`] row when [`OutlineIcons::Nerd`] is active. +/// [`crate::outline::OutlineItem::Dir`] row when [`OutlineIcons::Nerd`] is active. devicons is a +/// per-file mapper (it has no directory entries), so this stays our own constant. pub const DIR_ICON: char = '\u{f07b}'; // nf-fa-folder -/// The fallback file icon (nerd-font `nf-fa-file`, U+F15B) for any extension not in -/// [`icon_for_path`]'s table (including extensionless files). +/// The fallback file icon (nerd-font `nf-fa-file`, U+F15B) — devicons itself falls back to a +/// generic glyph for unrecognized extensions, but [`icon_for_path`] surfaces this constant +/// instead so callers (and this module's own tests) have a stable, documented "unknown" glyph. pub const DEFAULT_ICON: char = '\u{f15b}'; // nf-fa-file -/// Look up the nerd-font glyph for `path`'s extension — small, deliberately-curated table -/// covering the languages this crate's own `highlight.rs` already bundles grammars for -/// (`lang_key_for_ext`), plus a couple of common project files. Every codepoint below is in the -/// nerd-font private-use area (`seti`/`devicons`/`fa` icon sets); unrecognized extensions and -/// extensionless files fall back to [`DEFAULT_ICON`]. -pub fn icon_for_path(path: &str) -> char { - // `Cargo.lock`/other `*.lock` files: match on the file NAME first, since "lock" isn't a - // meaningful extension-based language distinction the way the rest of the table is. - let name = path.rsplit('/').next().unwrap_or(path); - if name.ends_with(".lock") { - return '\u{f023}'; // nf-fa-lock +/// Look up the nerd-font glyph and brand color for `path`, via [`devicons::icon_for_file`]. +/// `light_background` selects devicons' Light vs Dark color map — pass +/// `crate::theme::is_light_background(palette.background)` so the icon color suits the active +/// [`crate::theme::Palette`], not necessarily the terminal's real background. +/// +/// Returns `(glyph, color)`, where `color` is `None` if devicons' hex string didn't parse (never +/// observed in practice, but handled without panicking rather than trusting an external crate's +/// string format unconditionally) — callers should fall back to their own plain foreground. +pub fn icon_for_path(path: &str, light_background: bool) -> (char, Option) { + let devicons_theme = Some(if light_background { + DeviconsTheme::Light + } else { + DeviconsTheme::Dark + }); + let mut resolved = icon_for_file(path, &devicons_theme); + if resolved.icon == FileIcon::default().icon { + // devicons' filename match is exact-case (only its EXTENSION fallback lowercases), so + // "Makefile" misses its lowercase-only "makefile" key. Mirror its extension strategy: + // exact first (some keys, e.g. "PKGBUILD"/".Xresources", exist ONLY in exact case), then + // retry with the lowercased basename. `FileIcon::default().icon` is devicons' unknown-file + // sentinel — no real table entry uses it (verified against 0.6.12's tables). + let name = path.rsplit('/').next().unwrap_or(path).to_lowercase(); + resolved = icon_for_file(name.as_str(), &devicons_theme); + } + if resolved.icon == FileIcon::default().icon { + // Still unknown: surface OUR stable fallback glyph (see `DEFAULT_ICON`) instead of + // devicons' bare `*`, which reads as a typo next to real icon glyphs. + return (DEFAULT_ICON, None); } - let ext = match name.rsplit_once('.') { - Some((_, ext)) => ext, - None => return DEFAULT_ICON, - }; - match ext { - "rs" => '\u{e7a8}', // seti-rust - "lua" => '\u{e620}', // seti-lua - "js" | "mjs" | "cjs" => '\u{e74e}', // seti-javascript - "jsx" | "tsx" => '\u{e7ba}', // seti-react - "ts" | "mts" | "cts" => '\u{e628}', // seti-typescript - "json" => '\u{e60b}', // seti-json - "toml" => '\u{e6b2}', // seti-config (toml has no dedicated seti glyph) - "md" | "markdown" => '\u{e73e}', // seti-markdown - _ => DEFAULT_ICON, + (resolved.icon, parse_hex_color(resolved.color)) +} + +/// Parse a `"#rrggbb"` hex string (devicons' [`FileIcon::color`] format) into a +/// [`ratatui::style::Color::Rgb`]. Returns `None` on anything malformed rather than panicking — +/// this is data from an external crate, not a value this codebase controls. +fn parse_hex_color(hex: &str) -> Option { + let hex = hex.strip_prefix('#')?; + if hex.len() != 6 { + return None; } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(Color::Rgb(r, g, b)) } #[cfg(test)] @@ -63,29 +97,80 @@ mod tests { use super::*; #[test] - fn known_extensions_map_to_their_glyphs() { - assert_eq!(icon_for_path("src/main.rs"), '\u{e7a8}'); - assert_eq!(icon_for_path("scripts/init.lua"), '\u{e620}'); - assert_eq!(icon_for_path("index.js"), '\u{e74e}'); - assert_eq!(icon_for_path("app.mjs"), '\u{e74e}'); - assert_eq!(icon_for_path("component.tsx"), '\u{e7ba}'); - assert_eq!(icon_for_path("component.jsx"), '\u{e7ba}'); - assert_eq!(icon_for_path("types.ts"), '\u{e628}'); - assert_eq!(icon_for_path("package.json"), '\u{e60b}'); - assert_eq!(icon_for_path("Cargo.toml"), '\u{e6b2}'); - assert_eq!(icon_for_path("README.md"), '\u{e73e}'); + fn known_extensions_map_to_devicons_glyphs() { + // Pinned against devicons 0.6.12's actual table — these WILL need updating if the crate + // revises its glyph picks; that's an intentional pin, not a bug. + assert_eq!(icon_for_path("src/main.rs", false).0, '\u{e68b}'); + assert_eq!(icon_for_path("index.js", false).0, '\u{e60c}'); + assert_eq!(icon_for_path("component.tsx", false).0, '\u{e7ba}'); + assert_eq!(icon_for_path("types.ts", false).0, '\u{e628}'); + assert_eq!(icon_for_path("package.json", false).0, '\u{e71e}'); + assert_eq!(icon_for_path("Cargo.toml", false).0, '\u{e6b2}'); + assert_eq!(icon_for_path("README.md", false).0, '\u{f48a}'); + } + + #[test] + fn filename_precedence_matches_our_previous_lock_file_handling() { + // devicons matches on filename before extension, same shape as the old hand-rolled table. + assert_eq!(icon_for_path("Cargo.lock", false).0, '\u{e672}'); + assert_eq!(icon_for_path("nested/dir/yarn.lock", false).0, '\u{e672}'); + } + + #[test] + fn newly_covered_project_files_resolve_to_devicons_glyphs() { + // Names our old 8-entry table didn't cover — devicons' broader table does. + assert_eq!(icon_for_path("Makefile", false).0, '\u{e779}'); + assert_eq!(icon_for_path("Dockerfile", false).0, '\u{f0868}'); + assert_eq!(icon_for_path(".gitignore", false).0, '\u{e702}'); + } + + #[test] + fn filename_match_retries_lowercased_when_the_exact_case_misses() { + // devicons' own filename lookup is exact-case; its table has "makefile" but no + // "Makefile", so without our lowercased retry the standard spelling would fall through + // to the unknown-file fallback. + assert_eq!( + icon_for_path("Makefile", false), + icon_for_path("makefile", false) + ); + assert_eq!(icon_for_path("nested/dir/LICENSE", false).0, '\u{e60a}'); + } + + #[test] + fn unknown_files_fall_back_to_our_default_icon_not_devicons_asterisk() { + // devicons returns a literal '*' for unknown files; `icon_for_path` surfaces our stable + // DEFAULT_ICON (with no color) instead — see `DEFAULT_ICON`'s doc comment. + assert_eq!( + icon_for_path("file.zzznotreal", false), + (DEFAULT_ICON, None) + ); + assert_eq!(icon_for_path("noextension", false), (DEFAULT_ICON, None)); + } + + #[test] + fn colors_differ_between_dark_and_light_themes_for_a_branded_extension() { + let (_, dark_color) = icon_for_path("src/main.rs", false); + let (_, light_color) = icon_for_path("src/main.rs", true); + assert!(dark_color.is_some()); + assert!(light_color.is_some()); } #[test] - fn lock_files_match_on_name_not_extension() { - assert_eq!(icon_for_path("Cargo.lock"), '\u{f023}'); - assert_eq!(icon_for_path("nested/dir/yarn.lock"), '\u{f023}'); + fn parse_hex_color_accepts_well_formed_rrggbb() { + assert_eq!( + parse_hex_color("#a074c4"), + Some(Color::Rgb(0xa0, 0x74, 0xc4)) + ); + assert_eq!(parse_hex_color("#000000"), Some(Color::Rgb(0, 0, 0))); + assert_eq!(parse_hex_color("#ffffff"), Some(Color::Rgb(255, 255, 255))); } #[test] - fn unknown_and_extensionless_paths_fall_back_to_the_default_icon() { - assert_eq!(icon_for_path("Makefile"), DEFAULT_ICON); - assert_eq!(icon_for_path("script.sh"), DEFAULT_ICON); - assert_eq!(icon_for_path("noextension"), DEFAULT_ICON); + fn parse_hex_color_rejects_malformed_input_without_panicking() { + assert_eq!(parse_hex_color("a074c4"), None); // missing '#' + assert_eq!(parse_hex_color("#a074c"), None); // too short + assert_eq!(parse_hex_color("#a074c400"), None); // too long + assert_eq!(parse_hex_color("#zzzzzz"), None); // not hex digits + assert_eq!(parse_hex_color(""), None); } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 8c7dbf5..2e7386b 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -613,11 +613,7 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) } else { tree_prefix(guides) }; - let icon = match icons { - OutlineIcons::Nerd => format!("{} ", crate::icons::icon_for_path(path)), - OutlineIcons::None => String::new(), - }; - Line::from(vec![ + let mut spans = vec![ TSpan::styled( format!("{prefix}{glyph}"), Style::default().fg(theme.foreground), @@ -626,11 +622,23 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) letter.to_string(), Style::default().fg(change_letter_color(*change, theme)), ), - TSpan::styled( - format!(" {icon}{path}"), - Style::default().fg(theme.foreground), - ), - ]) + TSpan::styled(" ".to_string(), Style::default().fg(theme.foreground)), + ]; + if icons == OutlineIcons::Nerd { + let (icon, color) = crate::icons::icon_for_path( + path, + crate::theme::is_light_background(theme.background), + ); + spans.push(TSpan::styled( + format!("{icon} "), + Style::default().fg(color.unwrap_or(theme.foreground)), + )); + } + spans.push(TSpan::styled( + path.clone(), + Style::default().fg(theme.foreground), + )); + Line::from(spans) } } } @@ -2908,7 +2916,7 @@ mod tests { .find(|r| r.contains("main.rs")) .expect("main.rs file row present"); assert!( - file_row.contains(crate::icons::icon_for_path("main.rs")), + file_row.contains(crate::icons::icon_for_path("main.rs", false).0), "expected the rust file icon before main.rs, got: {file_row:?}" ); } From c82e558925bf05418d42aacef85c1d6b83681176 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 11 Jul 2026 15:18:26 -0400 Subject: [PATCH 122/203] feat(review): promote semantic fg colors to palette knobs --- docs/adr/029-review-theming-base16-hybrid.md | 12 ++++ git-workon-review/src/render.rs | 64 ++++++++--------- git-workon-review/src/theme.rs | 73 ++++++++++++++++++-- 3 files changed, 114 insertions(+), 35 deletions(-) diff --git a/docs/adr/029-review-theming-base16-hybrid.md b/docs/adr/029-review-theming-base16-hybrid.md index 8b71263..7759213 100644 --- a/docs/adr/029-review-theming-base16-hybrid.md +++ b/docs/adr/029-review-theming-base16-hybrid.md @@ -132,6 +132,18 @@ read left untested (see `terminal_query.rs`). to render; `FgSpan` loses its `Color` field in favor of a capture index. Existing render tests that assert concrete colors must resolve through a fixed test `Palette`. +## Revised (CS2, visual-polish pass) + +The "chrome that is never a theme knob (error/warn/current-marker) stays ANSI/const in +`render.rs`" clause above is superseded. Those three colors are now `Palette` fields +(`error_fg`/`warn_fg`/`current_fg`, mapped to base08/base0A/base0B) rather than module +consts — the user explicitly approved revisiting this boundary during the icons/semantic-fg +polish pass. `dark()` keeps the shipped RGB values verbatim (the same pixel-identity +precedent the diff/cursor tints follow); `light()` takes `ONE_LIGHT`'s base08/base0A/base0B; +`from_terminal()` takes the probed scheme's base08/base0A/base0B directly, same reasoning as +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. + ## References - [ADR-028](028-review-git-native-config-schema.md) — `workon.review.theme` config key diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 2e7386b..2c1b389 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -30,20 +30,11 @@ use crate::wordiff::Span as WordSpan; // The on-tint colors (diff add/del gradient + staged variants, cursor/selection washes, and syntax // foreground) come from a [`Palette`] threaded through render (ADR-029). The canvas background and // default/dim/gutter chrome foreground ALSO now come from the palette (`theme.background`/ -// `theme.foreground`/`theme.dim`/`theme.gutter`) — see the theme module's revised hybrid-boundary -// doc comment — so a curated theme fully controls the look. Only semantic chrome that is never a -// theme knob (error/warn/current-marker) stays ANSI-named / const below. - -/// Footer text color for an [`Severity::Error`] [`Notice`] — a clearly-red tone that reads on -/// both light and dark terminal themes. -const FG_ERROR: Color = Color::Rgb(220, 60, 60); -/// Warning tone for the winbar's needs-restack marker (locked decision #9) — an amber, distinct -/// from [`FG_ERROR`]'s red: a stale-parent changeset is a heads-up to `gt restack`, not a failure. -const FG_WARN: Color = Color::Rgb(214, 158, 46); -/// Tone for the outline's "this is the lib-marked `current` changeset" marker (locked decision -/// #9's outline half) — a green, distinct from every other marker color in this module so -/// "current" reads unambiguously at a glance. -const FG_CURRENT: Color = Color::Rgb(96, 200, 128); +// `theme.foreground`/`theme.dim`/`theme.gutter`), as does the semantic chrome that used to be +// const here — error/warn/current-marker are now `theme.error_fg`/`theme.warn_fg`/ +// `theme.current_fg` (CS2, revising ADR-029's hybrid boundary) — see the theme module's revised +// hybrid-boundary doc comment. A curated theme now fully controls the look; nothing in this +// module hardcodes a semantic color anymore. /// Blend the cursor row's tint into an existing background, so the cursor highlight composites /// with (rather than replaces) del/add/word-diff emphasis on the same row — the row highlight is @@ -470,7 +461,7 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// 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`) and needs-restack glyph -/// (amber ⚠, [`FG_WARN`] — locked decision #9's outline half); [`OutlineItem::File`]s carry an +/// (amber ⚠, [`crate::theme::Palette::warn_fg`] — locked decision #9's outline half); [`OutlineItem::File`]s carry an /// indent, a one-character staged-ness glyph (blank for a committed changeset's files — see /// [`crate::outline::StagedStatus`]'s doc comment for why no special-casing is needed here), and /// the path. The cursor row (the outline's OWN cursor — a separate coordinate space from the @@ -563,7 +554,7 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) let marker = if *current { "\u{25CF} " } else { " " }; let mut spans = vec![TSpan::styled( marker.to_string(), - Style::default().fg(FG_CURRENT), + Style::default().fg(theme.current_fg), )]; spans.push(TSpan::styled( label.clone(), @@ -572,12 +563,18 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) .add_modifier(Modifier::BOLD), )); if *needs_restack { - spans.push(TSpan::styled(" \u{26A0}", Style::default().fg(FG_WARN))); + spans.push(TSpan::styled( + " \u{26A0}", + Style::default().fg(theme.warn_fg), + )); } // ADR-031: a Failed changeset's marker wins over Pending's (a slot is never both, // but Failed is the more actionable state to surface if it somehow were). if *failed { - spans.push(TSpan::styled(" \u{2717}", Style::default().fg(FG_ERROR))); + spans.push(TSpan::styled( + " \u{2717}", + Style::default().fg(theme.error_fg), + )); } else if *loading { spans.push(TSpan::styled(" \u{2026}", Style::default().fg(theme.dim))); } @@ -705,7 +702,9 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { if cs.needs_restack { spans.push(TSpan::styled( " ⚠ needs restack", - Style::default().fg(FG_WARN).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.warn_fg) + .add_modifier(Modifier::BOLD), )); } let fidx = app.current + 1; @@ -726,7 +725,7 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, theme: &Palette) { if let Some(confirm) = &app.pending_confirm { frame.render_widget( - Paragraph::new(confirm.prompt.as_str()).style(Style::default().fg(FG_ERROR)), + Paragraph::new(confirm.prompt.as_str()).style(Style::default().fg(theme.error_fg)), area, ); return; @@ -734,7 +733,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, them match &app.notice { Some(Notice { text, severity }) => { let fg = match severity { - Severity::Error => FG_ERROR, + Severity::Error => theme.error_fg, Severity::Info => theme.foreground, }; frame.render_widget( @@ -918,7 +917,7 @@ fn changeset_summary_lines( let mut title_spans = vec![TSpan::styled( if summary.current { "\u{25CF} " } else { " " }, - Style::default().fg(FG_CURRENT), + Style::default().fg(theme.current_fg), )]; title_spans.push(TSpan::styled( summary.label.clone(), @@ -927,7 +926,10 @@ fn changeset_summary_lines( .add_modifier(Modifier::BOLD), )); if summary.needs_restack { - title_spans.push(TSpan::styled(" \u{26A0}", Style::default().fg(FG_WARN))); + title_spans.push(TSpan::styled( + " \u{26A0}", + Style::default().fg(theme.warn_fg), + )); } lines.push(Line::from(title_spans)); @@ -938,7 +940,7 @@ fn changeset_summary_lines( .unwrap_or("(no error message)"); lines.push(Line::from(TSpan::styled( format!("\u{2717} {msg}"), - Style::default().fg(FG_ERROR), + Style::default().fg(theme.error_fg), ))); return lines; } @@ -1012,7 +1014,7 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { if let Some(message) = app.current_failure() { let msg = format!("Failed to load this changeset: {message}"); frame.render_widget( - Paragraph::new(msg).style(Style::default().fg(FG_ERROR)), + Paragraph::new(msg).style(Style::default().fg(theme.error_fg)), area, ); return; @@ -2298,7 +2300,7 @@ mod tests { ); assert_eq!( buf.cell((0, footer_y)).unwrap().style().fg, - Some(super::FG_ERROR), + Some(Palette::dark().error_fg), "expected the error notice to render in the error fg color" ); } @@ -2438,7 +2440,7 @@ mod tests { let marker_x = header.find('⚠').expect("restack glyph present") as u16; assert_eq!( buf.cell((marker_x, 0)).unwrap().style().fg, - Some(super::FG_WARN), + Some(Palette::dark().warn_fg), "expected the restack glyph to carry the warning color, not the plain header color" ); } @@ -2650,8 +2652,8 @@ mod tests { let marker_x = content[row].find('\u{25CF}').unwrap() as u16; assert_eq!( buf.cell((marker_x, row as u16)).unwrap().style().fg, - Some(super::FG_CURRENT), - "expected the outline's current marker to carry FG_CURRENT" + Some(Palette::dark().current_fg), + "expected the outline's current marker to carry Palette::dark().current_fg" ); } @@ -2672,8 +2674,8 @@ mod tests { let marker_x = content[row].find('\u{26A0}').unwrap() as u16; assert_eq!( buf.cell((marker_x, row as u16)).unwrap().style().fg, - Some(super::FG_WARN), - "expected the outline's restack glyph to carry FG_WARN" + Some(Palette::dark().warn_fg), + "expected the outline's restack glyph to carry Palette::dark().warn_fg" ); } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index cc4c475..2917eba 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -6,7 +6,7 @@ //! CS5 adds [`Palette::light`] and wires [`crate::config::Theme`] to pick between them; CS6 adds the //! terminal-derivation probe for `auto`. //! -//! ## Hybrid boundary (ADR-029, revised) +//! ## Hybrid boundary (ADR-029, twice-revised) //! Colors that sit ON a tinted background — the diff add/del gradient, its staged variants, the //! cursor/selection washes, and syntax foreground — are theme-controlled base16 truecolor and live //! here, as before. The canvas background and chrome FOREGROUND (default text, dim labels, the @@ -15,9 +15,17 @@ //! look instead of bleeding the terminal's own bg/fg through. `auto` ([`Palette::from_terminal`]) //! still derives these four from the probed terminal colors — so it matches the terminal exactly — //! and leaves [`Palette::paint_canvas`] `false` so a transparent/backgrounded terminal isn't -//! painted over; the curated schemes and the probe's curated fallback set it `true`. Semantic -//! chrome that is never on a tint and never a theme knob — error/warn/current-marker colors — stays -//! ANSI/const in [`crate::render`] (`FG_ERROR`/`FG_WARN`/`FG_CURRENT`), unaffected by this boundary. +//! painted over; the curated schemes and the probe's curated fallback set it `true`. +//! +//! **CS2 revision:** semantic chrome — error/warn/current-marker colors — was previously ANSI/const +//! in `crate::render` (`FG_ERROR`/`FG_WARN`/`FG_CURRENT`), deliberately excluded from the palette on +//! the reasoning that these colors never sit on a tint and are never a theme knob. That boundary is +//! now revised: they ARE palette knobs ([`Palette::error_fg`]/[`Palette::warn_fg`]/ +//! [`Palette::current_fg`], mapped to base08/base0A/base0B), so a curated or probed theme can shift +//! 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). use ratatui::style::Color; @@ -202,6 +210,20 @@ pub struct Palette { pub dim: Color, /// Gutter/divider foreground (base04) — line-number gutters and pane dividers. pub gutter: 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-029's hybrid boundary — see this + /// module's doc comment). + pub error_fg: Color, + /// Warning tone for a needs-restack marker (locked decision #9) — an amber (base0A), distinct + /// from [`Palette::error_fg`]'s red: a stale-parent changeset is a heads-up to `gt restack`, + /// not a failure. Promoted from `render.rs`'s `FG_WARN` const (CS2). + pub warn_fg: Color, + /// Tone for the outline's "this is the lib-marked `current` changeset" marker (locked + /// decision #9's outline half) — a green (base0B), distinct from every other marker color so + /// "current" reads unambiguously at a glance. Promoted from `render.rs`'s `FG_CURRENT` const + /// (CS2). + pub current_fg: Color, /// Whether [`crate::render::render`] should paint the whole frame with [`Palette::background`] /// before drawing panes. `true` for the curated [`Palette::dark`]/[`Palette::light`] schemes /// (and the probe's curated fallback); `false` for [`Palette::from_terminal`], so `auto` @@ -237,6 +259,11 @@ impl Palette { foreground: base.slot(5), dim: base.slot(3), gutter: base.slot(4), + // 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), + warn_fg: Color::Rgb(214, 158, 46), + current_fg: Color::Rgb(96, 200, 128), paint_canvas: true, } } @@ -286,6 +313,9 @@ impl Palette { foreground: base.slot(5), dim: base.slot(3), gutter: base.slot(4), + error_fg: red, + warn_fg: base.slot(10), // base0A + current_fg: green, paint_canvas: true, } } @@ -324,6 +354,11 @@ impl Palette { foreground: base.slot(5), dim: base.slot(3), gutter: base.slot(4), + // 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), + warn_fg: base.slot(10), + current_fg: base.slot(11), // Unlike the curated schemes, `auto` must NOT paint over the terminal's own // background — base00 here IS the probed terminal bg, so painting a solid canvas // would defeat terminal transparency/background images for no benefit (the probed @@ -397,6 +432,15 @@ mod tests { assert_eq!(t.outline_cursor_unfocused_bg, Color::Rgb(35, 38, 55)); } + #[test] + fn dark_semantic_fg_matches_the_historical_render_rs_constants() { + // CS2's pixel-identity gate for the promoted `FG_ERROR`/`FG_WARN`/`FG_CURRENT` consts. + let t = Palette::dark(); + assert_eq!(t.error_fg, Color::Rgb(220, 60, 60)); + assert_eq!(t.warn_fg, Color::Rgb(214, 158, 46)); + assert_eq!(t.current_fg, Color::Rgb(96, 200, 128)); + } + #[test] fn dark_chrome_fields_match_the_eighties_dark_ramp_and_paint_the_canvas() { // `dark()`'s canvas/chrome must come from the SAME ramp `Palette::dark`'s syntax/tints @@ -502,6 +546,14 @@ mod tests { assert_eq!(color("variable"), Color::Rgb(0x38, 0x3a, 0x42)); // base05 fg } + #[test] + fn light_semantic_fg_takes_one_lights_base08_base0a_base0b() { + let t = Palette::light(); + assert_eq!(t.error_fg, Color::Rgb(0xca, 0x12, 0x43)); // base08 + assert_eq!(t.warn_fg, Color::Rgb(0xc1, 0x84, 0x01)); // base0A + assert_eq!(t.current_fg, Color::Rgb(0x50, 0xa1, 0x4f)); // base0B + } + /// A synthetic probed scheme with a distinct value in every slot and the given `base00`, so a /// test can assert `from_terminal`'s syntax slots came from the probed scheme (not a curated /// one) and read the base00 luminance branch. @@ -574,6 +626,19 @@ mod tests { assert!(!palette.paint_canvas); } + #[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`). + let probed = probed_base16(Color::Rgb(0x1a, 0x1a, 0x1a)); + let palette = Palette::from_terminal(probed); + assert_eq!(palette.error_fg, probed.slot(8)); + assert_eq!(palette.warn_fg, probed.slot(10)); + assert_eq!(palette.current_fg, probed.slot(11)); + assert_ne!(palette.error_fg, Palette::dark().error_fg); + } + #[test] fn is_light_background_splits_on_the_luminance_midpoint() { assert!(is_light_background(Base16::ONE_LIGHT.slot(0))); From d08d5d6e4a933f9b3c4783701ef40bbcf11b1b53 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 11 Jul 2026 15:28:39 -0400 Subject: [PATCH 123/203] feat(review): nerd-mode status and header/summary iconography --- git-workon-review/src/outline.rs | 13 ++ git-workon-review/src/render.rs | 307 ++++++++++++++++++++++++++----- 2 files changed, 270 insertions(+), 50 deletions(-) diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index e03dbb8..3627e13 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -124,6 +124,19 @@ impl StagedStatus { StagedStatus::Partial => '\u{25D0}', // ◐ } } + + /// The nerd-font equivalent of [`StagedStatus::glyph`] (CS3, `workon.review.outline.icons = + /// nerd`) — picked from the classic BMP `fa` set for wider font compatibility (see + /// `icons.rs`'s module doc). [`StagedStatus::None`] stays a blank space, same as + /// [`StagedStatus::glyph`], since there's no status to convey. + pub fn nerd_glyph(self) -> char { + match self { + StagedStatus::None => ' ', + StagedStatus::Unstaged => '\u{f067}', // nf-fa-plus + StagedStatus::Staged => '\u{f00c}', // nf-fa-check + StagedStatus::Partial => '\u{f042}', // nf-fa-adjust + } + } } /// One file's outline-relevant data, as extracted from its owning changeset by diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 2c1b389..a03fd04 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -36,6 +36,114 @@ use crate::wordiff::Span as WordSpan; // hybrid-boundary doc comment. A curated theme now fully controls the look; nothing in this // module hardcodes a semantic color anymore. +// CS3's nerd-mode status/header/summary glyphs (gated on `OutlineIcons::Nerd`; the plain unicode +// defaults below stay byte-identical when `icons = none` — see icons.rs's module doc for why no +// auto-detection ever picks Nerd for the user). Picked from the classic BMP nerd-font sets +// (`fa`/`oct`) rather than devicons' broader (partly supplementary-plane) table, for wider +// font compatibility — see `icons.rs`'s v3 doc note. +/// Nerd-mode "this is the current changeset" marker, replacing the plain `●` (U+25CF). +const NERD_CURRENT_MARKER: char = '\u{f111}'; // nf-fa-circle +/// Nerd-mode needs-restack marker, replacing the plain `⚠` (U+26A0). +const NERD_WARN_MARKER: char = '\u{f071}'; // nf-fa-warning +/// Nerd-mode failed-changeset marker, replacing the plain `✗` (U+2717). +const NERD_ERROR_MARKER: char = '\u{f00d}'; // nf-fa-times +/// Nerd-mode loading marker, replacing the plain `…` (U+2026). +const NERD_LOADING_MARKER: char = '\u{f141}'; // nf-fa-ellipsis-h +/// Nerd-mode branch glyph prepended to a changeset header row's title (both the outline's Header +/// row and the summary panel's changeset title) — purely decorative (dim-colored), so it carries +/// no semantic color of its own. +const NERD_BRANCH_ICON: char = '\u{f418}'; // nf-oct-git-branch +/// Nerd-mode diffstat glyph for the summary panel's added-lines count, replacing the plain `+`. +const NERD_DIFF_ADDED: char = '\u{f457}'; // nf-oct-diff-added +/// Nerd-mode diffstat glyph for the summary panel's deleted-lines count, replacing the plain `-`. +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. +fn current_marker(icons: OutlineIcons) -> char { + match icons { + OutlineIcons::Nerd => NERD_CURRENT_MARKER, + OutlineIcons::None => '\u{25CF}', + } +} + +/// The needs-restack marker for the active icon strategy (see [`current_marker`]). +fn warn_marker(icons: OutlineIcons) -> char { + match icons { + OutlineIcons::Nerd => NERD_WARN_MARKER, + OutlineIcons::None => '\u{26A0}', + } +} + +/// The failed-changeset marker for the active icon strategy (see [`current_marker`]). +fn error_marker(icons: OutlineIcons) -> char { + match icons { + OutlineIcons::Nerd => NERD_ERROR_MARKER, + OutlineIcons::None => '\u{2717}', + } +} + +/// The loading marker for the active icon strategy (see [`current_marker`]). +fn loading_marker(icons: OutlineIcons) -> char { + match icons { + OutlineIcons::Nerd => NERD_LOADING_MARKER, + OutlineIcons::None => '\u{2026}', + } +} + +/// The diffstat `+`/`-` prefixes for the active icon strategy (nerd: the oct diff glyphs) — +/// shared by the summary panel's totals line and any other diffstat surface. +fn diffstat_prefixes(icons: OutlineIcons) -> (String, String) { + match icons { + OutlineIcons::Nerd => ( + format!("{NERD_DIFF_ADDED} "), + format!("{NERD_DIFF_REMOVED} "), + ), + OutlineIcons::None => ("+".to_string(), "-".to_string()), + } +} + +/// The shared changeset-title span run — `[current-marker] [branch-icon] label [warn-marker]` — +/// drawn identically by `build_outline_line`'s Header arm and [`changeset_summary_lines`] (whose +/// doc comment promises exactly that sameness); extracting it makes the promise structural +/// instead of hand-synced. Failed/loading markers are NOT included: the two call sites place +/// them differently (trailing spans on the header row vs. a line of their own in the summary). +fn changeset_title_spans( + label: &str, + current: bool, + needs_restack: bool, + theme: &Palette, + icons: OutlineIcons, +) -> Vec> { + let marker = if current { + format!("{} ", current_marker(icons)) + } else { + " ".to_string() + }; + let mut spans = vec![TSpan::styled(marker, Style::default().fg(theme.current_fg))]; + if icons == OutlineIcons::Nerd { + spans.push(TSpan::styled( + format!("{NERD_BRANCH_ICON} "), + Style::default().fg(theme.dim), + )); + } + spans.push(TSpan::styled( + label.to_string(), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), + )); + if needs_restack { + spans.push(TSpan::styled( + format!(" {}", warn_marker(icons)), + Style::default().fg(theme.warn_fg), + )); + } + spans +} + /// Blend the cursor row's tint into an existing background, so the cursor highlight composites /// with (rather than replaces) del/add/word-diff emphasis on the same row — the row highlight is /// a wash over the whole row, not a mask. `None` (a context/gap cell with no bg span at all) @@ -551,32 +659,19 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) failed, .. } => { - let marker = if *current { "\u{25CF} " } else { " " }; - let mut spans = vec![TSpan::styled( - marker.to_string(), - Style::default().fg(theme.current_fg), - )]; - spans.push(TSpan::styled( - label.clone(), - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), - )); - if *needs_restack { - spans.push(TSpan::styled( - " \u{26A0}", - Style::default().fg(theme.warn_fg), - )); - } + let mut spans = changeset_title_spans(label, *current, *needs_restack, theme, icons); // ADR-031: a Failed changeset's marker wins over Pending's (a slot is never both, // but Failed is the more actionable state to surface if it somehow were). if *failed { spans.push(TSpan::styled( - " \u{2717}", + format!(" {}", error_marker(icons)), Style::default().fg(theme.error_fg), )); } else if *loading { - spans.push(TSpan::styled(" \u{2026}", Style::default().fg(theme.dim))); + spans.push(TSpan::styled( + format!(" {}", loading_marker(icons)), + Style::default().fg(theme.dim), + )); } Line::from(spans) } @@ -600,7 +695,10 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) guides, .. } => { - let glyph = status.glyph(); + let glyph = match icons { + OutlineIcons::Nerd => status.nerd_glyph(), + OutlineIcons::None => status.glyph(), + }; let letter = change.letter(); // Empty `guides` (Flat/Stack modes) keeps the original two-space indent; a // non-empty `guides` (Tree/StackTree modes) draws tree connectors instead — see @@ -882,11 +980,13 @@ fn push_summary_body( total_dels: usize, height: usize, theme: &Palette, + icons: OutlineIcons, ) { lines.push(Line::from("")); let footer_budget = 1; // the totals line always shows let file_budget = height.saturating_sub(lines.len() + footer_budget); push_summary_file_rows(lines, files, file_budget, theme); + let (added_prefix, removed_prefix) = diffstat_prefixes(icons); lines.push(Line::from(vec![ TSpan::styled( format!("{} files", files.len()), @@ -894,44 +994,35 @@ fn push_summary_body( ), TSpan::raw(" "), TSpan::styled( - format!("+{total_adds}"), + format!("{added_prefix}{total_adds}"), Style::default().fg(theme.add_strong), ), TSpan::raw(" "), TSpan::styled( - format!("-{total_dels}"), + format!("{removed_prefix}{total_dels}"), Style::default().fg(theme.del_strong), ), ])); } -/// Build a [`ChangesetSummary`]'s lines: title line (carrying the same current/needs-restack/ -/// failed markers `build_outline_line`'s Header arm draws), a loading/failed line OR the per-file -/// list + totals line. +/// Build a [`ChangesetSummary`]'s lines: title line (the same current/needs-restack markers +/// `build_outline_line`'s Header arm draws — structurally shared via [`changeset_title_spans`]), +/// a loading/failed line OR the per-file list + totals line. fn changeset_summary_lines( summary: &ChangesetSummary, height: usize, theme: &Palette, + icons: OutlineIcons, ) -> Vec> { let mut lines = Vec::new(); - let mut title_spans = vec![TSpan::styled( - if summary.current { "\u{25CF} " } else { " " }, - Style::default().fg(theme.current_fg), - )]; - title_spans.push(TSpan::styled( - summary.label.clone(), - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), - )); - if summary.needs_restack { - title_spans.push(TSpan::styled( - " \u{26A0}", - Style::default().fg(theme.warn_fg), - )); - } - lines.push(Line::from(title_spans)); + lines.push(Line::from(changeset_title_spans( + &summary.label, + summary.current, + summary.needs_restack, + theme, + icons, + ))); if summary.failed { let msg = summary @@ -939,14 +1030,14 @@ fn changeset_summary_lines( .as_deref() .unwrap_or("(no error message)"); lines.push(Line::from(TSpan::styled( - format!("\u{2717} {msg}"), + format!("{} {msg}", error_marker(icons)), Style::default().fg(theme.error_fg), ))); return lines; } if summary.loading { lines.push(Line::from(TSpan::styled( - "Loading\u{2026}", + format!("Loading{}", loading_marker(icons)), Style::default().fg(theme.dim), ))); return lines; @@ -959,15 +1050,27 @@ fn changeset_summary_lines( summary.total_dels, height, theme, + icons, ); 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). -fn dir_summary_lines(summary: &DirSummary, height: usize, theme: &Palette) -> Vec> { +/// The title gets [`crate::icons::DIR_ICON`] in [`OutlineIcons::Nerd`] mode, matching the +/// outline's own [`OutlineItem::Dir`] row (`build_outline_line`). +fn dir_summary_lines( + summary: &DirSummary, + height: usize, + theme: &Palette, + icons: OutlineIcons, +) -> Vec> { + let dir_icon = match icons { + OutlineIcons::Nerd => format!("{} ", crate::icons::DIR_ICON), + OutlineIcons::None => String::new(), + }; let mut lines = vec![Line::from(TSpan::styled( - format!("{}/", summary.path), + format!("{dir_icon}{}/", summary.path), Style::default() .fg(theme.foreground) .add_modifier(Modifier::BOLD), @@ -979,6 +1082,7 @@ fn dir_summary_lines(summary: &DirSummary, height: usize, theme: &Palette) -> Ve summary.total_dels, height, theme, + icons, ); lines } @@ -988,11 +1092,17 @@ fn dir_summary_lines(summary: &DirSummary, height: usize, theme: &Palette) -> Ve /// 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`]). -fn render_summary(frame: &mut Frame, summary: &Summary, area: Rect, theme: &Palette) { +fn render_summary( + frame: &mut Frame, + summary: &Summary, + area: Rect, + theme: &Palette, + icons: OutlineIcons, +) { let height = area.height as usize; let lines = match summary { - Summary::Changeset(cs) => changeset_summary_lines(cs, height, theme), - Summary::Dir(dir) => dir_summary_lines(dir, height, theme), + 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); } @@ -1004,7 +1114,7 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { // diff-body rendering; `summary_target` returns `None` in both cases). if let Some(target) = app.summary_target() { let summary = app.summary_for(target); - render_summary(frame, &summary, area, theme); + render_summary(frame, &summary, area, theme, app.outline_icons()); return; } // ADR-031: the active changeset's diff hasn't been acquired (or failed to acquire) yet — @@ -2958,6 +3068,103 @@ mod tests { ); } + // ── CS3: nerd-mode status/header/summary iconography ──────────────────────── + + #[test] + fn outline_header_nerd_markers_replace_the_unicode_defaults() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); // cs-b: current + needs_restack + app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0: it's the full-width winbar, which ALSO renders a (still-unicode, CS4's job) + // "⚠ needs restack" marker — an unskipped search would false-positive on it. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let joined = content.join("\n"); + + assert!( + joined.contains(super::NERD_CURRENT_MARKER), + "expected the nerd current-changeset marker, got:\n{joined}" + ); + assert!( + joined.contains(super::NERD_WARN_MARKER), + "expected the nerd needs-restack marker, got:\n{joined}" + ); + assert!( + !joined.contains('\u{25CF}') && !joined.contains('\u{26A0}'), + "nerd mode must not leave the plain unicode markers behind in the outline pane, got:\n{joined}" + ); + assert!( + joined.contains(super::NERD_BRANCH_ICON), + "expected a branch glyph on the changeset header row, got:\n{joined}" + ); + } + + #[test] + fn outline_file_status_nerd_glyph_replaces_the_plain_glyph() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("a.txt", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + if !app.outline_open() { + app.toggle_outline(); + } + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let joined = content.join("\n"); + + assert!( + joined.contains(crate::outline::StagedStatus::Staged.nerd_glyph()), + "expected the nerd staged glyph (fa-check), got:\n{joined}" + ); + assert!( + !joined.contains(crate::outline::StagedStatus::Staged.glyph()), + "nerd mode must not leave the plain ✓ glyph behind, got:\n{joined}" + ); + } + + #[test] + fn summary_panel_nerd_mode_renders_the_dir_icon_and_diffstat_glyphs() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = changeset_with_nested_paths(&fixture); + app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + app.focus_outline(); // opens (a lone changeset defaults closed) and focuses + app.outline_cycle_mode(); // Stack -> 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::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 body = body_text(&buf); + assert!( + body.contains(crate::icons::DIR_ICON), + "expected the summary panel's dir title to carry the nerd dir icon, got:\n{body}" + ); + assert!( + body.contains(super::NERD_DIFF_ADDED) && body.contains(super::NERD_DIFF_REMOVED), + "expected nerd diffstat glyphs in the summary panel's totals line, got:\n{body}" + ); + } + // ── CS4: summary panel ─────────────────────────────────────────────────────── /// The body area's columns, for a render at [`OUTLINE_TEST_WIDTH`] (outline `0..35`, divider From 8e6e67ef65a70a626714b76aa4b51a2f9a354a39 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 11 Jul 2026 15:45:31 -0400 Subject: [PATCH 124/203] style(review): tree guides, gap label, and winbar restyle --- git-workon-review/src/outline.rs | 2 +- git-workon-review/src/render.rs | 186 ++++++++++++++++++++++++++----- 2 files changed, 161 insertions(+), 27 deletions(-) diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 3627e13..4248e46 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -184,7 +184,7 @@ pub struct OutlineChangeset { /// nesting level from the shallowest ancestor down to the row itself, `true` meaning "this /// level is its parent's last child". Rendering uses every-element-but-the-last to decide /// whether to draw a continuing `│` or blank space at that column, and the last element to draw -/// `└─`/`├─` for the row's own connector. [`OutlineMode::Flat`]/[`OutlineMode::Stack`] rows carry +/// `╰─`/`├─` for the row's own connector (CS4 rounds the last-child corner). [`OutlineMode::Flat`]/[`OutlineMode::Stack`] rows carry /// an EMPTY `guides` — that's the signal to `render::build_outline_line` to fall back to the /// flat two-space indent instead of drawing tree connectors; a non-empty `guides` of length 1 /// means "top-level tree row" (depth 0), so emptiness and depth-0 are deliberately distinguishable. diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index a03fd04..73ee1c5 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -613,7 +613,9 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) /// Render a tree-guide prefix from an [`OutlineItem::Dir`]/[`OutlineItem::File`] `guides` /// vector: every element but the last draws a continuing `│` (if that ancestor level was NOT /// its parent's last child) or blank space (if it was), and the last element draws the row's own -/// `└─`/`├─` connector. +/// `╰─`/`├─` connector — CS4 rounds the last-child corner (`╰`, U+2570) from the square `└` +/// (U+2514); there's no widely-supported rounded "tee" glyph, so the non-last `├─` connector is +/// unchanged. fn tree_prefix(guides: &[bool]) -> String { let mut s = String::new(); let Some((&is_last, ancestors)) = guides.split_last() else { @@ -623,7 +625,7 @@ fn tree_prefix(guides: &[bool]) -> String { s.push_str(if last { " " } else { "\u{2502} " }); } s.push_str(if is_last { - "\u{2514}\u{2500} " + "\u{2570}\u{2500} " } else { "\u{251C}\u{2500} " }); @@ -702,23 +704,35 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) let letter = change.letter(); // Empty `guides` (Flat/Stack modes) keeps the original two-space indent; a // non-empty `guides` (Tree/StackTree modes) draws tree connectors instead — see - // `OutlineItem`'s doc comment for why emptiness is the mode signal. - let prefix = if guides.is_empty() { - " ".to_string() + // `OutlineItem`'s doc comment for why emptiness is the mode signal. CS4: a non-empty + // prefix (real tree connectors) gets its own `theme.dim`-styled span — matching the + // Dir row's already-dim guides — so the guide lines read as quiet structure, not part + // of the file's own status glyph; the empty two-space indent has nothing visible to + // dim, so it stays bundled with the glyph span below. + let mut spans = Vec::new(); + if guides.is_empty() { + spans.push(TSpan::styled( + format!(" {glyph}"), + Style::default().fg(theme.foreground), + )); } else { - tree_prefix(guides) - }; - let mut spans = vec![ - TSpan::styled( - format!("{prefix}{glyph}"), + spans.push(TSpan::styled( + tree_prefix(guides), + Style::default().fg(theme.dim), + )); + spans.push(TSpan::styled( + glyph.to_string(), Style::default().fg(theme.foreground), - ), - TSpan::styled( - letter.to_string(), - Style::default().fg(change_letter_color(*change, theme)), - ), - TSpan::styled(" ".to_string(), Style::default().fg(theme.foreground)), - ]; + )); + } + spans.push(TSpan::styled( + letter.to_string(), + Style::default().fg(change_letter_color(*change, theme)), + )); + spans.push(TSpan::styled( + " ".to_string(), + Style::default().fg(theme.foreground), + )); if icons == OutlineIcons::Nerd { let (icon, color) = crate::icons::icon_for_path( path, @@ -779,15 +793,22 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { } /// 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 +/// (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 [`OutlineIcons::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) { let cs = app.current_changeset(); let i = app.current_cs() + 1; let n = app.changeset_count(); let title = cs.title.as_deref().unwrap_or(cs.name.as_str()); + let icons = app.outline_icons(); let mut spans = vec![TSpan::styled( format!("[{i}/{n}] {title}"), @@ -799,16 +820,60 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { // 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( - " ⚠ needs restack", + 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-031) — 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( - format!(" — {} ({fidx}/{nfiles})", current_file_label(app)), + " — ".to_string(), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), + )); + if icons == OutlineIcons::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), + ); + spans.push(TSpan::styled( + format!("{icon} "), + Style::default() + .fg(color.unwrap_or(theme.foreground)) + .add_modifier(Modifier::BOLD), + )); + } + } + spans.push(TSpan::styled( + format!("{} ({fidx}/{nfiles})", current_file_label(app)), Style::default() .fg(theme.foreground) .add_modifier(Modifier::BOLD), @@ -2555,6 +2620,49 @@ mod tests { ); } + #[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. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + + 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:?}" + ); + } + + #[test] + fn winbar_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.set_outline_icons(crate::icons::OutlineIcons::Nerd); + + 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(super::NERD_WARN_MARKER) && !header.contains('\u{26A0}'), + "expected the nerd restack marker, not the plain unicode one, got: {header:?}" + ); + assert!( + header.contains(super::NERD_DIFF_ADDED) && header.contains(super::NERD_DIFF_REMOVED), + "expected nerd diffstat glyphs in the winbar, 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:?}" + ); + } + #[test] fn winbar_uses_title_when_present() { let fixture = FixtureBuilder::new() @@ -2911,18 +3019,44 @@ mod tests { content.join("\n") ); assert!( - content[2].contains('\u{2514}') && content[2].contains("a.txt"), - "expected row 2 to be src/a.txt, indented under src/ with its own last-child '└─' \ - guide, got:\n{}", + content[2].contains('\u{2570}') && content[2].contains("a.txt"), + "expected row 2 to be src/a.txt, indented under src/ with its own last-child \ + rounded '╰─' guide, got:\n{}", content.join("\n") ); assert!( - content[3].contains('\u{2514}') && content[3].contains("top.txt"), - "expected row 3 to be top.txt with a last-child '└─' guide, got:\n{}", + content[3].contains('\u{2570}') && content[3].contains("top.txt"), + "expected row 3 to be top.txt with a last-child rounded '╰─' guide, got:\n{}", content.join("\n") ); } + #[test] + fn outline_file_row_tree_guide_carries_the_dim_color() { + // CS4: a File row's tree-guide connector (distinct from its status glyph, which keeps + // `theme.foreground`) is styled `theme.dim`, matching the Dir row's already-dim guides. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = changeset_with_nested_paths(&fixture); + if !app.outline_open() { + app.toggle_outline(); + } + app.outline_cycle_mode(); // Stack -> Tree + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Row 3 is top.txt (see the test above) — a File row with a non-empty guide vector. + let row = outline_row(&buf, 3); + let guide_x = row.find('\u{2570}').expect("rounded guide present") as u16; + assert_eq!( + buf.cell((guide_x, 3)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected the File row's tree-guide connector to carry theme.dim, got: {row:?}" + ); + } + // ── CS5: file status letter + opt-in nerd-font icons ─────────────────────────── #[test] From 0c5c1527fdc9ad277e5cce8e198c9e2881e4e5c6 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 11 Jul 2026 20:04:49 -0400 Subject: [PATCH 125/203] refactor(review): promote icons config to workon.review.icons --- git-workon-review/src/app.rs | 72 ++++++++++++------------ git-workon-review/src/config.rs | 36 ++++++------ git-workon-review/src/icons.rs | 17 +++--- git-workon-review/src/outline.rs | 2 +- git-workon-review/src/render.rs | 96 ++++++++++++++++---------------- 5 files changed, 114 insertions(+), 109 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 9532802..8473e40 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -23,7 +23,7 @@ use crate::align::{ use crate::apply::{Git2Applier, StageVerb}; use crate::config::RawViewConfig; use crate::highlight::{lang_key_for_ext, FgSpan, TsHighlighter}; -use crate::icons::OutlineIcons; +use crate::icons::IconMode; use crate::model::{DiffModel, FileChange, FileStatus, Hunk, LineKind}; use crate::ops; use crate::outline::{self, OutlineChangeset, OutlineFile, OutlineItem, OutlineMode, OutlineOrder}; @@ -674,14 +674,14 @@ fn parse_outline_order(raw: &str) -> Option { } } -/// Parse `workon.review.outline.icons` (CS5) into an [`OutlineIcons`]. Canonical strings mirror +/// 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 [`OutlineIcons::default`] (also `none` — CS5's +/// [`App::apply_view_config`] falls back to [`IconMode::default`] (also `none` — CS5's /// no-auto-detection default) and warns. -fn parse_outline_icons(raw: &str) -> Option { +fn parse_icon_mode(raw: &str) -> Option { match raw { - "nerd" => Some(OutlineIcons::Nerd), - "none" => Some(OutlineIcons::None), + "nerd" => Some(IconMode::Nerd), + "none" => Some(IconMode::None), _ => None, } } @@ -800,10 +800,6 @@ pub struct OutlineState { /// Which end of the stack the stack-shaped modes display first — `workon.review.outline.order` /// (CS3), defaulting to [`OutlineOrder::HeadFirst`]. Read by [`App::outline_items`]. pub order: OutlineOrder, - /// CS5: opt-in nerd-font file/dir icons — `workon.review.outline.icons`, defaulting to - /// [`OutlineIcons::None`] (no auto-detection story exists — a terminal can't report the - /// user's font). Read by `render::build_outline_line`. - pub icons: OutlineIcons, } /// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the @@ -1144,6 +1140,11 @@ pub struct App { /// rebuilt-from-scratch — `open`/`focused`/`mode` persist, like [`Self::layout`]/ /// [`Self::zoom`]) by every diff-initiated nav and by [`Self::refresh`]. outline: OutlineState, + /// Opt-in nerd-font iconography — `workon.review.icons`, defaulting to [`IconMode::None`] + /// (no auto-detection story exists — a terminal can't report the user's font). A TUI-wide + /// appearance mode like the theme, not an outline view setting: it gates the outline's + /// file/dir icons AND the summary panel's and winbar's glyphs (see `render.rs`). + icon_mode: IconMode, /// Whether the `?` help overlay is showing (CS3). While `true`, `tui::update` intercepts /// every key as a modal (mirroring [`Self::pending_confirm`]'s capture) — see its doc comment /// for the precedence between the two modals. @@ -1311,7 +1312,6 @@ impl App { width: DEFAULT_OUTLINE_WIDTH, scroll: 0, order: OutlineOrder::default(), - icons: OutlineIcons::default(), }; let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial @@ -1349,6 +1349,7 @@ impl App { selection_anchor: None, refresh_coordinator, outline, + icon_mode: IconMode::default(), help_visible: false, review_source: None, defer_loads: false, @@ -2524,10 +2525,10 @@ impl App { self.outline.order } - /// CS5: whether the outline renders nerd-font icons — `workon.review.outline.icons`, or - /// [`OutlineIcons::default`] (`None`) if never set. - pub fn outline_icons(&self) -> OutlineIcons { - self.outline.icons + /// The nerd-font iconography mode — `workon.review.icons`, or [`IconMode::default`] + /// (`None`) if never set. TUI-wide: read by the outline, summary panel, and winbar renderers. + pub fn icon_mode(&self) -> IconMode { + self.icon_mode } /// `o`: a pure show/hide toggle — closed -> open+focused (+[`Self::sync_outline_to_current`]), @@ -2602,12 +2603,11 @@ impl App { self.outline.order = order; } - /// Set the outline icons setting directly — the config-startup (CS5) counterpart; there is - /// no interactive key for this (icons are a static config choice, not something to toggle - /// mid-session). Same non-resync posture as [`Self::set_outline_mode`]/ - /// [`Self::set_outline_order`]. - pub fn set_outline_icons(&mut self, icons: OutlineIcons) { - self.outline.icons = icons; + /// Set the icon mode directly — the config-startup counterpart; there is no interactive + /// key for this (icons are a static config choice, not something to toggle mid-session). + /// Same non-resync posture as [`Self::set_outline_mode`]/[`Self::set_outline_order`]. + pub fn set_icon_mode(&mut self, icons: IconMode) { + self.icon_mode = icons; } /// Move the outline's own cursor by `delta` rows (`j`/`k` while the outline has focus), @@ -3314,16 +3314,16 @@ impl App { }; self.set_outline_order(order); - let icons = match &raw.outline_icons { - Some(i) => parse_outline_icons(i).unwrap_or_else(|| { + let icons = match &raw.icons { + Some(i) => parse_icon_mode(i).unwrap_or_else(|| { warnings.push(format!( - "workon.review.outline.icons = '{i}' unrecognized; using default" + "workon.review.icons = '{i}' unrecognized; using default" )); - OutlineIcons::default() + IconMode::default() }), - None => OutlineIcons::default(), + None => IconMode::default(), }; - self.set_outline_icons(icons); + self.set_icon_mode(icons); let layout = match &raw.diff_layout { Some(l) => parse_diff_layout(l).unwrap_or_else(|| { @@ -4379,7 +4379,7 @@ mod tests { }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::ReviewConfig; - use crate::icons::OutlineIcons; + use crate::icons::IconMode; use crate::model::FileStatus; use crate::outline::{OutlineItem, OutlineMode, OutlineOrder, StagedStatus}; @@ -8992,7 +8992,7 @@ mod tests { assert_eq!(app.outline_width(), DEFAULT_OUTLINE_WIDTH); assert_eq!(app.outline_mode(), OutlineMode::default()); assert_eq!(app.outline_order(), OutlineOrder::default()); - assert_eq!(app.outline_icons(), OutlineIcons::default()); + assert_eq!(app.icon_mode(), IconMode::default()); assert_eq!(app.layout, Layout::default()); assert_eq!(app.zoom, Zoom::default()); } @@ -9091,9 +9091,9 @@ mod tests { } #[test] - fn outline_icons_overrides_default_when_set() { + fn icon_mode_overrides_default_when_set() { let fixture = FixtureBuilder::new() - .config("workon.review.outline.icons", "nerd") + .config("workon.review.icons", "nerd") .build() .unwrap(); let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); @@ -9102,13 +9102,13 @@ mod tests { let warnings = app.apply_view_config(&config); assert!(warnings.is_empty()); - assert_eq!(app.outline_icons(), OutlineIcons::Nerd); + assert_eq!(app.icon_mode(), IconMode::Nerd); } #[test] - fn outline_icons_invalid_falls_back_to_default_with_warning() { + fn icon_mode_invalid_falls_back_to_default_with_warning() { let fixture = FixtureBuilder::new() - .config("workon.review.outline.icons", "bogus") + .config("workon.review.icons", "bogus") .build() .unwrap(); let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); @@ -9116,9 +9116,9 @@ mod tests { let warnings = app.apply_view_config(&config); - assert_eq!(app.outline_icons(), OutlineIcons::default()); + assert_eq!(app.icon_mode(), IconMode::default()); assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("outline.icons")); + assert!(warnings[0].contains("workon.review.icons")); } #[test] diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index 0f8e867..4b52ee1 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -18,6 +18,7 @@ //! ```gitconfig //! [workon "review"] //! theme = dark ; auto | dark | light (default: auto) +//! icons = nerd ; nerd | none (default: none) //! //! [workon "review.diff.bind"] //! stage-hunk = s x ; action = key tokens (space-separated) @@ -32,19 +33,20 @@ //! width = 32 //! mode = tree //! order = base-first ; head-first | base-first (default: head-first) -//! icons = nerd ; nerd | none (default: none) //! //! [workon "review.diff"] //! layout = split //! zoom = combined //! ``` //! -//! ## `outline.icons` (CS5) +//! ## `icons` //! -//! Opt-in nerd-font file/dir icons in the outline pane. There is deliberately NO auto-detection +//! Opt-in nerd-font iconography — top-level next to `theme` (`workon.review.icons`), NOT an +//! outline setting: the mode gates the outline's file/dir icons, the summary panel's glyphs, +//! and the winbar's marker/diffstat/file icons alike. There is deliberately NO auto-detection //! — a terminal cannot report whether the user's font is patched with the nerd-font glyphs, so //! guessing would silently render tofu/mojibake for anyone without one. Default is `none` -//! (today's plain text); set `icons = nerd` explicitly once your terminal font supports it. See +//! (plain text); set `icons = nerd` explicitly once your terminal font supports it. See //! [`crate::icons`] for the glyph table. use git2::Repository; @@ -112,7 +114,7 @@ pub struct RawViewConfig { pub outline_width: Option, pub outline_mode: Option, pub outline_order: Option, - pub outline_icons: Option, + pub icons: Option, pub diff_layout: Option, pub diff_zoom: Option, } @@ -220,10 +222,15 @@ impl<'repo> ReviewConfig<'repo> { self.get_view_string(View::Outline, "order") } - /// Get `workon.review.outline.icons`, raw. `None` if unset — callers apply the current - /// default ([`crate::icons::OutlineIcons::None`], CS5: no auto-detection story exists). - pub fn outline_icons(&self) -> Result, git2::Error> { - self.get_view_string(View::Outline, "icons") + /// Get `workon.review.icons`, raw. `None` if unset — callers apply the current default + /// ([`crate::icons::IconMode::None`]; no auto-detection story exists). Top-level like + /// `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") { + Ok(val) => Ok(Some(val)), + Err(_) => Ok(None), + } } /// Get `workon.review.diff.layout`, raw. `None` if unset. @@ -248,7 +255,7 @@ impl<'repo> ReviewConfig<'repo> { outline_width: self.outline_width().ok().flatten(), outline_mode: self.outline_mode().ok().flatten(), outline_order: self.outline_order().ok().flatten(), - outline_icons: self.outline_icons().ok().flatten(), + icons: self.icons().ok().flatten(), diff_layout: self.diff_layout().ok().flatten(), diff_zoom: self.diff_zoom().ok().flatten(), } @@ -433,7 +440,7 @@ mod tests { .config("workon.review.outline.width", "40") .config("workon.review.outline.mode", "tree") .config("workon.review.outline.order", "base-first") - .config("workon.review.outline.icons", "nerd") + .config("workon.review.icons", "nerd") .config("workon.review.diff.layout", "split") .config("workon.review.diff.zoom", "staged") .build() @@ -450,10 +457,7 @@ mod tests { config.outline_order().expect("order"), Some("base-first".to_string()) ); - assert_eq!( - config.outline_icons().expect("icons"), - Some("nerd".to_string()) - ); + assert_eq!(config.icons().expect("icons"), Some("nerd".to_string())); assert_eq!( config.diff_layout().expect("layout"), Some("split".to_string()) @@ -473,7 +477,7 @@ mod tests { assert_eq!(config.outline_width().expect("width"), None); assert_eq!(config.outline_mode().expect("mode"), None); assert_eq!(config.outline_order().expect("order"), None); - assert_eq!(config.outline_icons().expect("icons"), None); + assert_eq!(config.icons().expect("icons"), None); assert_eq!(config.diff_layout().expect("layout"), None); assert_eq!(config.diff_zoom().expect("zoom"), None); } diff --git a/git-workon-review/src/icons.rs b/git-workon-review/src/icons.rs index dc8f969..aa68c6c 100644 --- a/git-workon-review/src/icons.rs +++ b/git-workon-review/src/icons.rs @@ -3,7 +3,7 @@ //! //! A terminal cannot report which font (patched with the nerd-font private-use glyphs or not) //! the user has configured, so there is NO auto-detection here or anywhere else in the crate — -//! icons are strictly opt-in via `workon.review.outline.icons = nerd` (see `config.rs`'s schema +//! icons are strictly opt-in via `workon.review.icons = nerd` (see `config.rs`'s schema //! doc block and `App::apply_view_config`). With the config left at its default (`none`), //! nothing in this module is ever called from `render.rs`. //! @@ -14,7 +14,7 @@ //! picks one from the active [`crate::theme::Palette`] (see [`icon_for_path`]'s doc comment). //! **Nerd-font v3 requirement:** devicons' glyphs are drawn from nerd-font v3's private-use //! codepoints, roughly a fifth of which sit in a Unicode supplementary plane (outside the BMP). -//! The crate's own `OutlineIcons::Nerd` glyphs picked in CS3 (status/header markers) stay +//! The crate's own `IconMode::Nerd` glyphs picked in CS3 (status/header markers) stay //! BMP-only for wider font compatibility, but a per-file icon from devicons may require a v3 //! nerd-font — this is the same "no auto-detection" opt-in tradeoff as the rest of this module. //! devicons does not cover directories (it is a per-file mapper), so [`DIR_ICON`] is still ours. @@ -22,12 +22,13 @@ use devicons::{icon_for_file, FileIcon, Theme as DeviconsTheme}; use ratatui::style::Color; -/// Which of the outline's icon strategies is active — `workon.review.outline.icons` -/// (`nerd`/`none`), read once at startup by `App::apply_view_config` (CS5 mirrors CS3's -/// `OutlineOrder` plumbing exactly: `RawViewConfig` field -> `ReviewConfig` getter -> -/// `parse_outline_icons` -> warn-and-fallback in `apply_view_config` -> `OutlineState` field). +/// 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). +/// 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)] -pub enum OutlineIcons { +pub enum IconMode { /// No icon glyph — today's plain `[glyph][letter] path` row (CS5's unconditional part only). #[default] None, @@ -37,7 +38,7 @@ pub enum OutlineIcons { } /// The directory-row icon (nerd-font `nf-fa-folder`, U+F07B) — used for every -/// [`crate::outline::OutlineItem::Dir`] row when [`OutlineIcons::Nerd`] is active. devicons is a +/// [`crate::outline::OutlineItem::Dir`] row when [`IconMode::Nerd`] is active. devicons is a /// per-file mapper (it has no directory entries), so this stays our own constant. pub const DIR_ICON: char = '\u{f07b}'; // nf-fa-folder diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 4248e46..076e85a 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -125,7 +125,7 @@ impl StagedStatus { } } - /// The nerd-font equivalent of [`StagedStatus::glyph`] (CS3, `workon.review.outline.icons = + /// The nerd-font equivalent of [`StagedStatus::glyph`] (CS3, `workon.review.icons = /// nerd`) — picked from the classic BMP `fa` set for wider font compatibility (see /// `icons.rs`'s module doc). [`StagedStatus::None`] stays a blank space, same as /// [`StagedStatus::glyph`], since there's no status to convey. diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 73ee1c5..b3d4ec7 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -19,7 +19,7 @@ use crate::app::{ use crate::attribute::Attribution; use crate::config::View; use crate::highlight::FgSpan; -use crate::icons::OutlineIcons; +use crate::icons::IconMode; use crate::keymap::{footer_hint, help_sections, Keymap}; use crate::model::FileStatus; use crate::outline::OutlineItem; @@ -36,7 +36,7 @@ use crate::wordiff::Span as WordSpan; // hybrid-boundary doc comment. A curated theme now fully controls the look; nothing in this // module hardcodes a semantic color anymore. -// CS3's nerd-mode status/header/summary glyphs (gated on `OutlineIcons::Nerd`; the plain unicode +// CS3's nerd-mode status/header/summary glyphs (gated on `IconMode::Nerd`; the plain unicode // defaults below stay byte-identical when `icons = none` — see icons.rs's module doc for why no // auto-detection ever picks Nerd for the user). Picked from the classic BMP nerd-font sets // (`fa`/`oct`) rather than devicons' broader (partly supplementary-plane) table, for wider @@ -62,46 +62,46 @@ const NERD_DIFF_REMOVED: char = '\u{f458}'; // nf-oct-diff-removed /// 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. -fn current_marker(icons: OutlineIcons) -> char { +fn current_marker(icons: IconMode) -> char { match icons { - OutlineIcons::Nerd => NERD_CURRENT_MARKER, - OutlineIcons::None => '\u{25CF}', + IconMode::Nerd => NERD_CURRENT_MARKER, + IconMode::None => '\u{25CF}', } } /// The needs-restack marker for the active icon strategy (see [`current_marker`]). -fn warn_marker(icons: OutlineIcons) -> char { +fn warn_marker(icons: IconMode) -> char { match icons { - OutlineIcons::Nerd => NERD_WARN_MARKER, - OutlineIcons::None => '\u{26A0}', + IconMode::Nerd => NERD_WARN_MARKER, + IconMode::None => '\u{26A0}', } } /// The failed-changeset marker for the active icon strategy (see [`current_marker`]). -fn error_marker(icons: OutlineIcons) -> char { +fn error_marker(icons: IconMode) -> char { match icons { - OutlineIcons::Nerd => NERD_ERROR_MARKER, - OutlineIcons::None => '\u{2717}', + IconMode::Nerd => NERD_ERROR_MARKER, + IconMode::None => '\u{2717}', } } /// The loading marker for the active icon strategy (see [`current_marker`]). -fn loading_marker(icons: OutlineIcons) -> char { +fn loading_marker(icons: IconMode) -> char { match icons { - OutlineIcons::Nerd => NERD_LOADING_MARKER, - OutlineIcons::None => '\u{2026}', + IconMode::Nerd => NERD_LOADING_MARKER, + IconMode::None => '\u{2026}', } } /// The diffstat `+`/`-` prefixes for the active icon strategy (nerd: the oct diff glyphs) — /// shared by the summary panel's totals line and any other diffstat surface. -fn diffstat_prefixes(icons: OutlineIcons) -> (String, String) { +fn diffstat_prefixes(icons: IconMode) -> (String, String) { match icons { - OutlineIcons::Nerd => ( + IconMode::Nerd => ( format!("{NERD_DIFF_ADDED} "), format!("{NERD_DIFF_REMOVED} "), ), - OutlineIcons::None => ("+".to_string(), "-".to_string()), + IconMode::None => ("+".to_string(), "-".to_string()), } } @@ -115,7 +115,7 @@ fn changeset_title_spans( current: bool, needs_restack: bool, theme: &Palette, - icons: OutlineIcons, + icons: IconMode, ) -> Vec> { let marker = if current { format!("{} ", current_marker(icons)) @@ -123,7 +123,7 @@ fn changeset_title_spans( " ".to_string() }; let mut spans = vec![TSpan::styled(marker, Style::default().fg(theme.current_fg))]; - if icons == OutlineIcons::Nerd { + if icons == IconMode::Nerd { spans.push(TSpan::styled( format!("{NERD_BRANCH_ICON} "), Style::default().fg(theme.dim), @@ -588,7 +588,7 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) let cursor = app.outline_cursor(); let focused = app.outline_focused(); let scroll = app.outline_scroll(); - let icons = app.outline_icons(); + let icons = app.icon_mode(); let buf = frame.buffer_mut(); for row in 0..area.height { @@ -648,10 +648,10 @@ fn change_letter_color(change: FileStatus, theme: &Palette) -> Color { } /// Build one outline row's rendered [`Line`] — see [`render_outline`]'s doc comment for the -/// marker rules. `icons` (CS5, `workon.review.outline.icons`) is [`OutlineIcons::None`] by +/// marker rules. `icons` (CS5, `workon.review.icons`) is [`IconMode::None`] by /// default, which reproduces the pre-CS5 row text exactly (no icon glyph, no extra space); only -/// [`OutlineIcons::Nerd`] inserts an icon before the name/path. -fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) -> Line<'static> { +/// [`IconMode::Nerd`] inserts an icon before the name/path. +fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> Line<'static> { match item { OutlineItem::Header { label, @@ -679,8 +679,8 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) } OutlineItem::Dir { name, guides, .. } => { let icon = match icons { - OutlineIcons::Nerd => format!("{} ", crate::icons::DIR_ICON), - OutlineIcons::None => String::new(), + IconMode::Nerd => format!("{} ", crate::icons::DIR_ICON), + IconMode::None => String::new(), }; let text = format!("{}{icon}{name}/", tree_prefix(guides)); Line::from(TSpan::styled( @@ -698,8 +698,8 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) .. } => { let glyph = match icons { - OutlineIcons::Nerd => status.nerd_glyph(), - OutlineIcons::None => status.glyph(), + IconMode::Nerd => status.nerd_glyph(), + IconMode::None => status.glyph(), }; let letter = change.letter(); // Empty `guides` (Flat/Stack modes) keeps the original two-space indent; a @@ -733,7 +733,7 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) " ".to_string(), Style::default().fg(theme.foreground), )); - if icons == OutlineIcons::Nerd { + if icons == IconMode::Nerd { let (icon, color) = crate::icons::icon_for_path( path, crate::theme::is_light_background(theme.background), @@ -800,7 +800,7 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { /// /// 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 [`OutlineIcons::Nerd`] mode the restack marker and diffstat prefixes swap +/// 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) { @@ -808,7 +808,7 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { let i = app.current_cs() + 1; let n = app.changeset_count(); let title = cs.title.as_deref().unwrap_or(cs.name.as_str()); - let icons = app.outline_icons(); + let icons = app.icon_mode(); let mut spans = vec![TSpan::styled( format!("[{i}/{n}] {title}"), @@ -858,7 +858,7 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { .fg(theme.foreground) .add_modifier(Modifier::BOLD), )); - if icons == OutlineIcons::Nerd { + if icons == IconMode::Nerd { if let Some(f) = app.files().get(app.current) { let (icon, color) = crate::icons::icon_for_path( &f.path, @@ -1045,7 +1045,7 @@ fn push_summary_body( total_dels: usize, height: usize, theme: &Palette, - icons: OutlineIcons, + icons: IconMode, ) { lines.push(Line::from("")); let footer_budget = 1; // the totals line always shows @@ -1077,7 +1077,7 @@ fn changeset_summary_lines( summary: &ChangesetSummary, height: usize, theme: &Palette, - icons: OutlineIcons, + icons: IconMode, ) -> Vec> { let mut lines = Vec::new(); @@ -1122,17 +1122,17 @@ fn changeset_summary_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 [`OutlineIcons::Nerd`] mode, matching the +/// The title gets [`crate::icons::DIR_ICON`] in [`IconMode::Nerd`] mode, matching the /// outline's own [`OutlineItem::Dir`] row (`build_outline_line`). fn dir_summary_lines( summary: &DirSummary, height: usize, theme: &Palette, - icons: OutlineIcons, + icons: IconMode, ) -> Vec> { let dir_icon = match icons { - OutlineIcons::Nerd => format!("{} ", crate::icons::DIR_ICON), - OutlineIcons::None => String::new(), + IconMode::Nerd => format!("{} ", crate::icons::DIR_ICON), + IconMode::None => String::new(), }; let mut lines = vec![Line::from(TSpan::styled( format!("{dir_icon}{}/", summary.path), @@ -1162,7 +1162,7 @@ fn render_summary( summary: &Summary, area: Rect, theme: &Palette, - icons: OutlineIcons, + icons: IconMode, ) { let height = area.height as usize; let lines = match summary { @@ -1179,7 +1179,7 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { // diff-body rendering; `summary_target` returns `None` in both cases). if let Some(target) = app.summary_target() { let summary = app.summary_for(target); - render_summary(frame, &summary, area, theme, app.outline_icons()); + render_summary(frame, &summary, area, theme, app.icon_mode()); return; } // ADR-031: the active changeset's diff hasn't been acquired (or failed to acquire) yet — @@ -2645,7 +2645,7 @@ mod tests { .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); // cs-b: current + needs_restack - app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + app.set_icon_mode(crate::icons::IconMode::Nerd); let buf = render_once(&mut app, 80, 20); let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); @@ -3100,7 +3100,7 @@ mod tests { } #[test] - fn outline_icons_nerd_renders_the_rust_file_icon_and_the_dir_icon() { + fn icon_mode_nerd_renders_the_rust_file_icon_and_the_dir_icon() { use git2::Repository; use workon::{Changeset, ChangesetSpan}; @@ -3140,7 +3140,7 @@ mod tests { } app.outline_cycle_mode(); // Stack -> Tree, so `src/` renders as its own Dir row assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); - app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + app.set_icon_mode(crate::icons::IconMode::Nerd); let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); @@ -3168,7 +3168,7 @@ mod tests { } #[test] - fn outline_icons_none_renders_neither_icon() { + fn icon_mode_none_renders_neither_icon() { let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .build() @@ -3180,8 +3180,8 @@ mod tests { app.outline_cycle_mode(); // Stack -> Tree assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); assert_eq!( - app.outline_icons(), - crate::icons::OutlineIcons::None, + app.icon_mode(), + crate::icons::IconMode::None, "sanity: icons default to None" ); @@ -3211,7 +3211,7 @@ mod tests { .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); // cs-b: current + needs_restack - app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + app.set_icon_mode(crate::icons::IconMode::Nerd); let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); // Skip y=0: it's the full-width winbar, which ALSO renders a (still-unicode, CS4's job) @@ -3245,7 +3245,7 @@ mod tests { .build() .unwrap(); let mut app = app_from_fixture(&fixture); - app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + app.set_icon_mode(crate::icons::IconMode::Nerd); if !app.outline_open() { app.toggle_outline(); } @@ -3271,7 +3271,7 @@ mod tests { .build() .unwrap(); let mut app = changeset_with_nested_paths(&fixture); - app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + app.set_icon_mode(crate::icons::IconMode::Nerd); app.focus_outline(); // opens (a lone changeset defaults closed) and focuses app.outline_cycle_mode(); // Stack -> Tree, so a Dir row exists to focus assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); From 49e8dc1c2db7d4fcd973edfcfc6783c157a88d6a Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 22:04:23 -0400 Subject: [PATCH 126/203] feat(review): mouse support with click-to-focus and wheel scroll --- git-workon-review/src/app.rs | 397 +++++++++++++++++++++++++++++++- git-workon-review/src/render.rs | 22 +- git-workon-review/src/tui.rs | 181 +++++++++++++-- 3 files changed, 577 insertions(+), 23 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 8473e40..7955265 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1049,6 +1049,47 @@ impl ChangesetView { } } +/// One content region the renderer painted this frame, in terminal cell coordinates (CS10). A +/// deliberately tiny local shape rather than `ratatui::layout::Rect`: `app.rs` has no ratatui +/// dependency today, and this keeps it that way — `render.rs` (which already depends on +/// ratatui) converts a `Rect`'s content area into this when it writes [`App::hit_regions`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Region { + pub x: u16, + pub y: u16, + pub w: u16, + pub h: u16, +} + +impl Region { + fn contains(&self, col: u16, row: u16) -> bool { + col >= self.x && col < self.x + self.w && row >= self.y && row < self.y + self.h + } +} + +/// The content regions the last frame painted (CS10), written by `render::render` (which clears +/// this to `Default` at the top of every frame first) and read by [`App::handle_click`]/ +/// [`App::handle_wheel`] to hit-test a mouse event's `(col, row)` against the region under the +/// pointer. A `None` field simply wasn't painted this frame — the outline is closed, or the +/// current file isn't in [`EffectiveZoom::Split`], etc. — never a stale rect from an earlier +/// frame's layout. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct HitRegions { + pub outline: Option, + pub single: Option, + pub unstaged: Option, + pub staged: Option, +} + +/// Which content region a mouse event hit-tested into (CS10's `App::hit_test`) — the outline, +/// the single-zoom diff pane, or one half of a split, tagged with which [`SplitPane`] so the +/// click/wheel handlers know whether to `toggle_split_focus` first. +enum HitPane { + Outline, + Single, + Split(SplitPane), +} + /// Review session state: the active changeset's file list, per-file lazily loaded views, and /// navigation/scroll state. One long-lived [`TsHighlighter`] lives here (not per file) — its /// language-config cache is keyed per-instance, so a fresh highlighter per file would rebuild @@ -1090,6 +1131,10 @@ pub struct App { /// Content height of the outline pane, written by the renderer each frame — same discipline /// as [`Self::pane_height`]. Read by [`Self::derive_outline_scroll`]. pub outline_height: usize, + /// The content regions the last frame painted (CS10 mouse support) — see [`HitRegions`]'s + /// doc comment. Cleared and re-written by `render::render` every frame; read by + /// [`Self::handle_click`]/[`Self::handle_wheel`]. + pub hit_regions: HitRegions, /// Label for the old side of the diff, shown next to a rename's `old_path` in the header. /// M4 only reviews the uncommitted (`HEAD` ↔ worktree) diffs, so this is always `"HEAD"` /// today; M5's committed-changeset zoom will want the changeset's actual base rev. @@ -1337,6 +1382,7 @@ impl App { alt: PaneState::default(), alt_height: 20, outline_height: 20, + hit_regions: HitRegions::default(), base_label, highlighter: TsHighlighter::new(), layout: Layout::default(), @@ -2563,6 +2609,120 @@ impl App { self.outline.focused = false; } + // ── Mouse (CS10) ───────────────────────────────────────────────────────────── + + /// Hit-test `(col, row)` against [`Self::hit_regions`] — outline first, then the single diff + /// pane, then the split's two halves — returning the matched region tagged with which + /// [`HitPane`] it was. `None` when the pointer is over a header/footer/divider/caption row + /// (recorded regions cover content only). + fn hit_test(&self, col: u16, row: u16) -> Option<(HitPane, Region)> { + if let Some(region) = self.hit_regions.outline { + if region.contains(col, row) { + return Some((HitPane::Outline, region)); + } + } + if let Some(region) = self.hit_regions.single { + if region.contains(col, row) { + return Some((HitPane::Single, region)); + } + } + if let Some(region) = self.hit_regions.unstaged { + if region.contains(col, row) { + return Some((HitPane::Split(SplitPane::Unstaged), region)); + } + } + if let Some(region) = self.hit_regions.staged { + if region.contains(col, row) { + return Some((HitPane::Split(SplitPane::Staged), region)); + } + } + None + } + + /// Focus the diff pane a click/wheel landed in, mirroring the keyboard focus rules: if the + /// outline had focus, `focus_diff()` moves focus onto whichever split pane already has it; if + /// the event landed in the OTHER split pane, `toggle_split_focus()` flips onto it next (never + /// assigning `split_focus` directly — see that method's doc comment). `target` is `None` for + /// the single-zoom pane, where there is no second half to flip to. + fn focus_diff_pane(&mut self, target: Option) { + if self.outline_focused() { + self.focus_diff(); + } + if let Some(target) = target { + if self.split_focus != target { + self.toggle_split_focus(); + } + } + } + + /// Set the (now-focused) pane's cursor to the row under a click, offset from `region`'s top by + /// `row` and clamped into the current row list, then re-derive `scroll`. A no-op on an empty + /// file list, matching [`Self::move_cursor_by`]'s empty-list behavior. + fn set_cursor_from_click(&mut self, region: Region, row: u16) { + let rows = self.row_count(); + if rows == 0 { + return; + } + let offset = (row - region.y) as usize; + self.cursor = (self.scroll + offset).min(rows - 1); + self.derive_scroll(); + } + + /// Left-click at terminal `(col, row)` (CS10): focus + select whatever content region the + /// click landed in, matching the keyboard-driven equivalent for that region. Outline: focuses + /// the outline and jumps the cursor to the clicked row via [`Self::outline_move_to`] — a File + /// row jumps the diff there (same single-jump semantics `g`/`G` use), a Header/Dir row just + /// selects (the summary panel follows via [`Self::summary_target`]). Diff pane (single or + /// split): focuses that pane (flipping `split_focus` first if the click landed in the + /// unfocused half) and moves its cursor to the clicked row. Outside every recorded region + /// (header/footer/divider/captions): no-op. + pub fn handle_click(&mut self, col: u16, row: u16) { + let Some((pane, region)) = self.hit_test(col, row) else { + return; + }; + match pane { + HitPane::Outline => { + self.focus_outline(); + let idx = self.outline.scroll + (row - region.y) as usize; + self.outline_move_to(idx); + } + HitPane::Single => { + self.focus_diff_pane(None); + self.set_cursor_from_click(region, row); + } + HitPane::Split(target) => { + self.focus_diff_pane(Some(target)); + self.set_cursor_from_click(region, row); + } + } + } + + /// Mouse wheel at terminal `(col, row)` with `delta` = ±3 rows (`tui::update` maps + /// `ScrollDown`/`ScrollUp` to +3/-3). Focuses whichever region the pointer sits over first — + /// same rule as [`Self::handle_click`] — then moves that pane's cursor by `delta` + /// ([`Self::outline_move_by`] for the outline, [`Self::move_cursor_by`] otherwise); scroll + /// simply follows via the normal derive discipline rather than a decoupled scroll state. + /// Outside every recorded region: no-op. + pub fn handle_wheel(&mut self, col: u16, row: u16, delta: i64) { + let Some((pane, _region)) = self.hit_test(col, row) else { + return; + }; + match pane { + HitPane::Outline => { + self.focus_outline(); + self.outline_move_by(delta); + } + HitPane::Single => { + self.focus_diff_pane(None); + self.move_cursor_by(delta); + } + HitPane::Split(target) => { + self.focus_diff_pane(Some(target)); + self.move_cursor_by(delta); + } + } + } + /// `?`: toggle the help overlay (CS3). A plain flip — the overlay always renders whatever /// view currently has keyboard focus (see `render::render_help_overlay`), so there is no /// extra state to reposition here, unlike [`Self::toggle_outline`]. @@ -4374,8 +4534,8 @@ 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, Layout, LoadedViews, Role, Severity, Summary, SummaryTarget, Zoom, - DEFAULT_OUTLINE_WIDTH, + EffectiveZoom, HitRegions, Layout, LoadedViews, Region, Role, Severity, Summary, + SummaryTarget, Zoom, DEFAULT_OUTLINE_WIDTH, }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::ReviewConfig; @@ -10059,4 +10219,237 @@ mod tests { view.display ); } + + // ── CS10: mouse (click-to-focus, wheel scrolling) ──────────────────────────── + + #[test] + fn click_on_an_outline_file_row_focuses_selects_and_jumps_the_diff() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + // BaseFirst Stack order: header(cs-a)=0, a1.txt=1, a2.txt=2, header(cs-b)=3, b1.txt=4. + app.outline_height = 10; + app.derive_outline_scroll(); + app.hit_regions.outline = Some(Region { + x: 0, + y: 0, + w: 20, + h: 10, + }); + assert!(!app.outline_focused(), "starts unfocused (locked default)"); + + // Row 2 (a2.txt) at the outline's top-of-viewport (scroll 0) is screen row 2. + app.handle_click(5, 2); + + assert!(app.outline_focused(), "a click on the outline focuses it"); + assert_eq!(app.outline_cursor(), 2); + assert_eq!(app.current_cs(), 0); + assert_eq!( + app.files()[app.current].path, + "a2.txt", + "a File row's click must jump the diff there, like outline_move_to" + ); + } + + #[test] + fn click_on_an_outline_header_row_selects_without_jumping_the_diff() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline_height = 10; + app.derive_outline_scroll(); + app.hit_regions.outline = Some(Region { + x: 0, + y: 0, + w: 20, + h: 10, + }); + let before_cs = app.current_cs(); + let before_file = app.current; + + // Row 3 is cs-b's header. + app.handle_click(5, 3); + + assert!(app.outline_focused()); + assert_eq!(app.outline_cursor(), 3); + assert_eq!( + (app.current_cs(), app.current), + (before_cs, before_file), + "a Header row's click must not jump the diff" + ); + assert!( + app.summary_target().is_some(), + "selecting a Header row (outline open + focused) must surface the summary panel" + ); + } + + #[test] + fn click_in_the_single_diff_pane_focuses_it_and_moves_the_cursor_to_the_clicked_row() { + // 40 single-line rows (mirrors `derive_scroll_keeps_scrolloff_margin_and_slides_minimally` + // above) — long enough that clicking row 4 lands there without clamping against a tiny + // real diff. + let lines: String = (1..=40).map(|n| format!("l{n}\n")).collect(); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("big.txt", &lines) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.focus_outline(); + assert!(app.outline_focused()); + app.pane_height = 10; + app.cursor = 0; + app.scroll = 0; + app.hit_regions.single = Some(Region { + x: 0, + y: 0, + w: 40, + h: 10, + }); + + app.handle_click(10, 4); + + assert!( + !app.outline_focused(), + "a click in the diff pane must return focus to the diff" + ); + assert_eq!( + app.cursor, 4, + "the cursor must land on the clicked row (scroll 0 + offset 4)" + ); + } + + #[test] + fn click_in_the_unfocused_split_pane_flips_split_focus_and_moves_its_cursor() { + let fixture = partial_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); // Split; focused pane defaults to Unstaged + assert_eq!(app.effective_zoom_for(app.current), EffectiveZoom::Split); + assert_eq!(app.split_focus_role(), Role::Unstaged); + + app.pane_height = 5; + app.alt_height = 5; + app.derive_scroll(); + app.derive_alt_scroll(); + app.hit_regions.unstaged = Some(Region { + x: 0, + y: 1, + w: 40, + h: 5, + }); + app.hit_regions.staged = Some(Region { + x: 0, + y: 7, + w: 40, + h: 5, + }); + + // Row 1 inside the staged region (y=7, height 5) — the currently UNFOCUSED pane. `f.txt` + // is a 3-line file (alpha/beta/gamma), so offset 1 stays within its row count either way. + app.handle_click(3, 8); + + assert_eq!( + app.split_focus_role(), + Role::Staged, + "a click in the unfocused pane must flip split_focus onto it" + ); + let (_, cursor) = app.pane_render_state(Role::Staged); + assert_eq!( + cursor, + Some(1), + "the newly-focused pane's cursor must land on the clicked row (offset 1 into the region)" + ); + } + + #[test] + fn wheel_over_the_outline_focuses_it_and_moves_the_cursor_by_delta_with_scrolloff() { + let mut app = four_committed_changesets_three_files_each(); + app.outline_height = 5; // bottom_margin = 5 - 1 - SCROLLOFF(2) = 2 + app.outline.cursor = 0; + app.derive_outline_scroll(); + app.hit_regions.outline = Some(Region { + x: 0, + y: 0, + w: 20, + h: 5, + }); + assert!(!app.outline_focused()); + + app.handle_wheel(5, 2, 3); + + assert!(app.outline_focused(), "a wheel event focuses its pane"); + assert_eq!(app.outline_cursor(), 3); + let scroll = app.outline_scroll(); + assert!( + app.outline_cursor() >= scroll && app.outline_cursor() <= scroll + 2, + "the outline scroll must follow the wheel-moved cursor via the normal scrolloff derive" + ); + } + + #[test] + fn wheel_over_the_focused_diff_pane_moves_the_cursor_by_delta() { + // Same 40-line fixture as the click test above — enough rows that a ±3 wheel move never + // clamps against a tiny real diff. + let lines: String = (1..=40).map(|n| format!("l{n}\n")).collect(); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("big.txt", &lines) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.pane_height = 10; + app.cursor = 5; + app.scroll = 0; + app.hit_regions.single = Some(Region { + x: 0, + y: 0, + w: 40, + h: 10, + }); + let cursor_before = app.cursor; + + app.handle_wheel(10, 3, 3); + assert_eq!(app.cursor, cursor_before + 3); + + app.handle_wheel(10, 3, -3); + assert_eq!(app.cursor, cursor_before); + } + + #[test] + fn click_outside_every_hit_region_is_a_no_op() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline_height = 10; + app.pane_height = 10; + app.hit_regions = HitRegions { + outline: Some(Region { + x: 0, + y: 0, + w: 20, + h: 10, + }), + single: Some(Region { + x: 21, + y: 0, + w: 40, + h: 10, + }), + unstaged: None, + staged: None, + }; + let outline_focused_before = app.outline_focused(); + let cursor_before = app.cursor; + let outline_cursor_before = app.outline_cursor(); + let current_before = (app.current_cs(), app.current); + + // Row 0 sits above both content regions (a header row at y=0 in either would collide — + // pick a column between the two panes' widths, on the divider itself). + app.handle_click(20, 0); + + assert_eq!(app.outline_focused(), outline_focused_before); + assert_eq!(app.cursor, cursor_before); + assert_eq!(app.outline_cursor(), outline_cursor_before); + assert_eq!((app.current_cs(), app.current), current_before); + } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index b3d4ec7..f78c38a 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -14,7 +14,7 @@ use ratatui::Frame; use crate::align::{CellKind, DisplayRow, InlineRow, Row}; use crate::app::{ - App, EffectiveZoom, FileView, Layout as AppLayout, Notice, Role, Severity, Summary, + App, EffectiveZoom, FileView, Layout as AppLayout, Notice, Region, Role, Severity, Summary, }; use crate::attribute::Attribution; use crate::config::View; @@ -452,6 +452,11 @@ fn build_pane_line( pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette) { let area = frame.area(); + // CS10: reset every recorded hit region at the start of the frame — a region only survives + // this frame if one of the panes below actually painted it again. Prevents a stale rect from + // an earlier frame's layout (e.g. the outline just closed) from staying hit-testable. + app.hit_regions = Default::default(); + // Paint the whole screen with the theme's background FIRST — a curated theme (light/dark) // controls the canvas outright; `auto` leaves `paint_canvas` false so the terminal's own // background (and any transparency) shows through instead. Everything drawn below only sets @@ -509,6 +514,17 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette } } +/// Convert a ratatui [`Rect`] into the [`Region`] shape [`App::hit_regions`] stores (CS10) — +/// `app.rs` has no ratatui dependency, so every write into `hit_regions` goes through this. +fn region_from(area: Rect) -> Region { + Region { + x: area.x, + y: area.y, + w: area.width, + h: area.height, + } +} + /// Compute a centered `percent_x` × `percent_y` sub-rect of `area` — the standard ratatui popup /// pattern (two nested percentage splits). fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { @@ -582,6 +598,7 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// transient bottom-anchor scroll computed fresh each frame. fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { app.outline_height = area.height as usize; + app.hit_regions.outline = Some(region_from(area)); let items = app.outline_items(); app.derive_outline_scroll(items.len()); @@ -1233,6 +1250,7 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { match app.effective_zoom_for(idx) { EffectiveZoom::Single(role) => { app.pane_height = area.height as usize; + app.hit_regions.single = Some(region_from(area)); let scroll = app.scroll; let cursor = Some(app.cursor); // The single pane is the focused one, so it shows any active selection. @@ -1292,6 +1310,8 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t }; app.pane_height = focused_h as usize; app.alt_height = unfocused_h as usize; + app.hit_regions.unstaged = Some(region_from(unstaged_content)); + app.hit_regions.staged = Some(region_from(staged_content)); app.derive_scroll(); app.derive_alt_scroll(); diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 822d213..5d8af8b 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -15,6 +15,13 @@ //! `event::poll`'s timeout to the channel's. The M4 index watcher's *semantics* are exactly //! unchanged by this move: it still compares [`workon_review::refresh::IndexSignature`] and //! re-diffs in place via [`App::on_tick`] on every `Tick`; only the beat's mechanism moved. +//! +//! CS10 turns the mouse on: [`Tui::acquire`] enables capture for the whole session (undone by +//! [`Tui::restore`] and, unconditionally, the panic hook), and [`map_terminal_event`] maps a +//! left-click or wheel-scroll into an [`AppEvent::Mouse`] the loop dispatches to +//! [`workon_review::app::App::handle_click`]/[`workon_review::app::App::handle_wheel`] — every +//! other mouse kind (drag, move, non-left buttons, button-up) is still dropped, same as key +//! release/repeat. use std::fs::File; use std::io::{self, Write}; @@ -23,7 +30,10 @@ use std::sync::mpsc; use std::thread; use std::time::Duration; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, + MouseButton, MouseEvent, MouseEventKind, +}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -52,6 +62,10 @@ use workon_review::theme::Palette; pub enum AppEvent { Key(KeyEvent), Resize(u16, u16), + /// A left-click or wheel-scroll (CS10) — the only [`MouseEventKind`]s [`map_terminal_event`] + /// maps; drag, move, non-left buttons, and up events are dropped at the mapping step, exactly + /// like key release/repeat. + Mouse(MouseEvent), Tick, /// One [`LoadRequest`]'s result — ADR-031's loader-result variant. `gen`/`cs_idx`/`file_idx` /// echo the request's stamp; `result` is `Err` for a job that panicked or otherwise failed @@ -98,15 +112,25 @@ impl PartialEq for AppEvent { /// still observable, just relayed rather than swallowed). `Tick` never appears here. type InboxMessage = io::Result; -/// Map one crossterm terminal [`Event`] to the [`AppEvent`] the loop reacts to — key-press and -/// resize map; key release/repeat, mouse, paste, and focus events are skipped (`None`), exactly -/// like this module's pre-ADR-031 `next_event`/`drain_pending` read arms did. Pure and -/// independent of any thread or channel, so it's unit-tested directly; the input thread's loop -/// body is a thin wrapper around it. +/// Map one crossterm terminal [`Event`] to the [`AppEvent`] the loop reacts to — key-press, +/// resize, and (CS10) a left-click or wheel-scroll map; key release/repeat, every other mouse +/// kind (drag, move, non-left buttons, button-up), paste, and focus events are skipped (`None`). +/// Pure and independent of any thread or channel, so it's unit-tested directly; the input +/// thread's loop body is a thin wrapper around it. fn map_terminal_event(event: Event) -> Option { match event { Event::Key(key) if key.kind == KeyEventKind::Press => Some(AppEvent::Key(key)), Event::Resize(w, h) => Some(AppEvent::Resize(w, h)), + Event::Mouse(m) + if matches!( + m.kind, + MouseEventKind::Down(MouseButton::Left) + | MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + ) => + { + Some(AppEvent::Mouse(m)) + } _ => None, } } @@ -695,6 +719,19 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap KeyOutcome::Handled => false, KeyOutcome::Action(action) => apply_action(app, action), }, + // CS10: both modals swallow mouse input exactly like they swallow keys (cases 1-2 above) + // — a click/wheel while a discard confirm or the help overlay is up does nothing. + AppEvent::Mouse(_) if app.pending_confirm.is_some() || app.help_visible => false, + AppEvent::Mouse(m) => { + app.clear_notice(); + match m.kind { + MouseEventKind::Down(MouseButton::Left) => app.handle_click(m.column, m.row), + MouseEventKind::ScrollDown => app.handle_wheel(m.column, m.row, 3), + MouseEventKind::ScrollUp => app.handle_wheel(m.column, m.row, -3), + _ => {} + } + false + } AppEvent::Tick => { app.on_tick(); false @@ -878,7 +915,11 @@ fn install_panic_hook() { std::panic::set_hook(Box::new(move |info| { let _ = disable_raw_mode(); let mut out = terminal_writer(); - let _ = execute!(out, LeaveAlternateScreen); + // CS10: disable mouse capture unconditionally, same as `Tui::restore` — a stray disable + // sequence when capture was never enabled (a panic before `Tui::acquire` reaches its own + // `EnableMouseCapture`) is harmless, and there's no cheaper way from here to know whether + // capture is currently on. + let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen); default_hook(info); })); } @@ -903,7 +944,10 @@ impl Tui { install_panic_hook(); enable_raw_mode()?; let mut out = terminal_writer(); - execute!(out, EnterAlternateScreen)?; + // CS10: capture the mouse for the whole session — `map_terminal_event` only ever lets a + // left-click or wheel-scroll through, so this doesn't cost the terminal's normal + // text-selection UX beyond what most terminals' shift-click bypass already covers. + execute!(out, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(out); let terminal = Terminal::new(backend)?; Ok(Self { @@ -1014,7 +1058,14 @@ impl Tui { } self.restored = true; disable_raw_mode()?; - execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?; + // CS10: disable mouse capture before leaving the alternate screen — same ordering + // convention as the raw-mode/alternate-screen pair, undone in the reverse order acquire + // set them up in. + execute!( + self.terminal.backend_mut(), + DisableMouseCapture, + LeaveAlternateScreen + )?; self.terminal.show_cursor() } } @@ -1144,9 +1195,18 @@ mod tests { ); } + fn mouse(kind: MouseEventKind) -> MouseEvent { + MouseEvent { + kind, + column: 5, + row: 7, + modifiers: KeyModifiers::NONE, + } + } + #[test] - fn map_terminal_event_skips_release_repeat_mouse_paste_and_focus() { - use crossterm::event::{KeyEventState, MouseEvent, MouseEventKind}; + fn map_terminal_event_skips_release_repeat_paste_and_focus() { + use crossterm::event::KeyEventState; let release = KeyEvent::new_with_kind( KeyCode::Char('q'), @@ -1163,20 +1223,57 @@ mod tests { ); assert_eq!(map_terminal_event(Event::Key(repeat)), None); - assert_eq!( - map_terminal_event(Event::Mouse(MouseEvent { - kind: MouseEventKind::Moved, - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - })), - None - ); assert_eq!(map_terminal_event(Event::Paste("pasted".to_string())), None); assert_eq!(map_terminal_event(Event::FocusGained), None); assert_eq!(map_terminal_event(Event::FocusLost), None); } + /// CS10: `map_terminal_event` maps ONLY a left-click-down or a wheel-scroll to + /// `AppEvent::Mouse`; every other mouse kind — drag, move, button-up, and non-left buttons — + /// is still dropped, exactly like the pre-CS10 version dropped every mouse event outright. + /// This supersedes the old `map_terminal_event_skips_release_repeat_mouse_paste_and_focus` + /// pin (split above into the non-mouse skip cases, which are unchanged by CS10). + #[test] + fn map_terminal_event_maps_left_down_and_scroll_but_drops_other_mouse_kinds() { + let left_down = mouse(MouseEventKind::Down(MouseButton::Left)); + assert!(matches!( + map_terminal_event(Event::Mouse(left_down)), + Some(AppEvent::Mouse(m)) if m == left_down + )); + + let scroll_up = mouse(MouseEventKind::ScrollUp); + assert!(matches!( + map_terminal_event(Event::Mouse(scroll_up)), + Some(AppEvent::Mouse(m)) if m == scroll_up + )); + + let scroll_down = mouse(MouseEventKind::ScrollDown); + assert!(matches!( + map_terminal_event(Event::Mouse(scroll_down)), + Some(AppEvent::Mouse(m)) if m == scroll_down + )); + + // Dropped: drag, move, button-up, and a right-click-down. + assert_eq!( + map_terminal_event(Event::Mouse(mouse(MouseEventKind::Drag(MouseButton::Left)))), + None + ); + assert_eq!( + map_terminal_event(Event::Mouse(mouse(MouseEventKind::Moved))), + None + ); + assert_eq!( + map_terminal_event(Event::Mouse(mouse(MouseEventKind::Up(MouseButton::Left)))), + None + ); + assert_eq!( + map_terminal_event(Event::Mouse(mouse(MouseEventKind::Down( + MouseButton::Right + )))), + None + ); + } + /// `AppEvent` dropped `PartialEq`/`Eq` in ADR-031 (`FileReady`'s `LoadedViews` payload wraps /// `FileView`, which has neither) — this test-only helper is the `matches!`-based replacement /// for the `assert_eq!(event, AppEvent::Key(key(...)))` shape used throughout this module's @@ -1881,6 +1978,50 @@ mod tests { repo.assert(predicate::repo::workdir_file_equals("a.txt", "one\ntwo\n")); } + /// CS10: a pending discard confirm swallows a mouse event exactly like it swallows a key — + /// mirrors `pending_confirm_captures_y_and_n_and_ignores_other_keys` above. A click inside a + /// live hit region must not move the cursor or resolve the confirm. + #[test] + fn pending_confirm_swallows_a_mouse_click() { + use workon_review::app::{PendingOp, Region}; + + let fixture = git_workon_fixture::prelude::FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\nthree\n", "one\nCHANGED\nthree\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.pane_height = 10; + app.hit_regions.single = Some(Region { + x: 0, + y: 0, + w: 40, + h: 10, + }); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + app.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); + let cursor_before = app.cursor; + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Mouse(mouse(MouseEventKind::Down(MouseButton::Left))), + ); + + assert!(!quit); + assert!( + app.pending_confirm.is_some(), + "a mouse event must not resolve the confirm" + ); + assert_eq!( + app.cursor, cursor_before, + "a swallowed click must not move the cursor" + ); + } + // ── M5 CS3: outline pane key routing ───────────────────────────────────── /// A two-committed-changeset stack, built the same way as `app.rs`/`render.rs`'s own M5 From d606117aa4152beb48411103a54c6f57e7b64f61 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 23:36:24 -0400 Subject: [PATCH 127/203] fix(review): compare AppEvent::Mouse structurally in PartialEq --- git-workon-review/src/tui.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 5d8af8b..9199a01 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -92,15 +92,20 @@ pub enum AppEvent { impl PartialEq for AppEvent { /// Manual, deliberately PARTIAL equality (can't derive — `FileReady`'s `LoadedViews` payload - /// isn't `PartialEq`, see the enum's doc comment): `Key`/`Resize`/`Tick` compare structurally, - /// exactly like the pre-ADR-031 derive did, for the input-thread tests that still assert - /// mapped-event shape via `assert_eq!`. Two `FileReady` events are never considered equal — - /// there's no sound definition of "the same loader result" once `FileView` can't be compared, - /// and nothing needs one; tests that care about a `FileReady`'s fields match on them directly. + /// isn't `PartialEq`, see the enum's doc comment): `Key`/`Resize`/`Mouse`/`Tick` compare + /// structurally, exactly like the pre-ADR-031 derive did, for the input-thread tests that + /// still assert mapped-event shape via `assert_eq!`. Two `FileReady` events are never + /// considered equal — there's no sound definition of "the same loader result" once `FileView` + /// can't be compared, and nothing needs one; tests that care about a `FileReady`'s fields + /// match on them directly. Every fully-comparable variant needs its own arm here: the + /// `_ => false` catch-all exists ONLY for `FileReady`/`ChangesetReady`, and letting a + /// comparable variant fall into it silently breaks reflexivity (`Mouse` did exactly that + /// when CS10 first added it — crossterm's `MouseEvent` derives `PartialEq` fine). fn eq(&self, other: &Self) -> bool { match (self, other) { (AppEvent::Key(a), AppEvent::Key(b)) => a == b, (AppEvent::Resize(w1, h1), AppEvent::Resize(w2, h2)) => w1 == w2 && h1 == h2, + (AppEvent::Mouse(a), AppEvent::Mouse(b)) => a == b, (AppEvent::Tick, AppEvent::Tick) => true, _ => false, } From b381d763499c28d6f3ad01c9a807e1b9f72265ff Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 23:53:51 -0400 Subject: [PATCH 128/203] fix(review): pass row count to derive_outline_scroll in mouse tests --- git-workon-review/src/app.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 7955265..ca9e7d0 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -10229,7 +10229,7 @@ mod tests { app.outline.order = OutlineOrder::BaseFirst; // BaseFirst Stack order: header(cs-a)=0, a1.txt=1, a2.txt=2, header(cs-b)=3, b1.txt=4. app.outline_height = 10; - app.derive_outline_scroll(); + app.derive_outline_scroll(app.outline_items().len()); app.hit_regions.outline = Some(Region { x: 0, y: 0, @@ -10257,7 +10257,7 @@ mod tests { app.outline.mode = OutlineMode::Stack; app.outline.order = OutlineOrder::BaseFirst; app.outline_height = 10; - app.derive_outline_scroll(); + app.derive_outline_scroll(app.outline_items().len()); app.hit_regions.outline = Some(Region { x: 0, y: 0, @@ -10367,7 +10367,7 @@ mod tests { let mut app = four_committed_changesets_three_files_each(); app.outline_height = 5; // bottom_margin = 5 - 1 - SCROLLOFF(2) = 2 app.outline.cursor = 0; - app.derive_outline_scroll(); + app.derive_outline_scroll(app.outline_items().len()); app.hit_regions.outline = Some(Region { x: 0, y: 0, From 12fd8bd1cacc05502d5aa27a09ef325dc7f342c2 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 11 Jul 2026 10:41:16 -0400 Subject: [PATCH 129/203] fix(review): mouse wheel scrolls the viewport and leaves the cursor --- git-workon-review/src/app.rs | 142 ++++++++++++++++++++++++++------ git-workon-review/src/render.rs | 50 ++++++++++- 2 files changed, 164 insertions(+), 28 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index ca9e7d0..91e5d77 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1125,8 +1125,8 @@ pub struct App { /// [`Self::toggle_split_focus`]). Meaningless outside [`EffectiveZoom::Split`]. alt: PaneState, /// Content height of the unfocused split pane, written by the renderer alongside - /// [`Self::pane_height`] — [`Self::derive_alt_scroll`] derives the unfocused pane's scroll - /// against THIS, not the focused pane's height. + /// [`Self::pane_height`] — the unfocused pane's scroll is clamped/derived against THIS, not + /// the focused pane's height (see [`Self::clamp_alt_scroll`]). pub(crate) alt_height: usize, /// Content height of the outline pane, written by the renderer each frame — same discipline /// as [`Self::pane_height`]. Read by [`Self::derive_outline_scroll`]. @@ -2699,10 +2699,10 @@ impl App { /// Mouse wheel at terminal `(col, row)` with `delta` = ±3 rows (`tui::update` maps /// `ScrollDown`/`ScrollUp` to +3/-3). Focuses whichever region the pointer sits over first — - /// same rule as [`Self::handle_click`] — then moves that pane's cursor by `delta` - /// ([`Self::outline_move_by`] for the outline, [`Self::move_cursor_by`] otherwise); scroll - /// simply follows via the normal derive discipline rather than a decoupled scroll state. - /// Outside every recorded region: no-op. + /// same rule as [`Self::handle_click`] — then scrolls that pane's VIEWPORT by `delta`, + /// leaving the cursor exactly where it was (the peek model: a wheel is "look elsewhere", + /// never "select elsewhere") — see [`Self::scroll_viewport_by`]. Outside every recorded + /// region: no-op. pub fn handle_wheel(&mut self, col: u16, row: u16, delta: i64) { let Some((pane, _region)) = self.hit_test(col, row) else { return; @@ -2710,19 +2710,48 @@ impl App { match pane { HitPane::Outline => { self.focus_outline(); - self.outline_move_by(delta); + self.outline_scroll_viewport_by(delta); } HitPane::Single => { self.focus_diff_pane(None); - self.move_cursor_by(delta); + self.scroll_viewport_by(delta); } HitPane::Split(target) => { self.focus_diff_pane(Some(target)); - self.move_cursor_by(delta); + self.scroll_viewport_by(delta); } } } + /// Scroll the focused pane's viewport by `delta` rows (mouse wheel), clamped to the row + /// list. The cursor is deliberately NOT touched (the peek model: a wheel is "look + /// elsewhere", never "select elsewhere"), so it can sit outside the viewport — the next + /// cursor-driven op re-derives the scroll and snaps the view back to it, which is the + /// peek model's recovery gesture, not a bug. This is the one place `scroll` is written + /// directly rather than derived from the cursor; the renderer's bounds-clamp (see + /// [`Self::clamp_scroll`]) is what lets the wheeled position survive frames. + fn scroll_viewport_by(&mut self, delta: i64) { + let rows = self.row_count(); + if rows == 0 { + return; + } + let max_scroll = rows.saturating_sub(self.pane_height.max(1)) as i64; + self.scroll = (self.scroll as i64 + delta).clamp(0, max_scroll.max(0)) as usize; + } + + /// The outline counterpart of [`Self::scroll_viewport_by`] — same peek model: the outline + /// cursor never moves (so wheeling past File rows can't jump the diff, and the summary + /// panel's target stays put); the next outline cursor op snaps the view back to it. + fn outline_scroll_viewport_by(&mut self, delta: i64) { + let rows = self.outline_items().len(); + if rows == 0 { + return; + } + let max_scroll = rows.saturating_sub(self.outline_height.max(1)) as i64; + self.outline.scroll = + (self.outline.scroll as i64 + delta).clamp(0, max_scroll.max(0)) as usize; + } + /// `?`: toggle the help overlay (CS3). A plain flip — the overlay always renders whatever /// view currently has keyboard focus (see `render::render_help_overlay`), so there is no /// extra state to reposition here, unlike [`Self::toggle_outline`]. @@ -3198,8 +3227,11 @@ impl App { } /// Re-derive the UNFOCUSED split pane's scroll against its own cursor, row count, and - /// [`Self::alt_height`] — called by the renderer each split frame, after the pane heights are - /// known. + /// [`Self::alt_height`]. Test-only since the wheel's peek model (CS10): the renderer now + /// bounds-clamps instead of deriving (see [`Self::clamp_alt_scroll`]), and no production + /// path derives the unfocused pane's scroll — the pair re-derives naturally once focus + /// swaps back onto it and a cursor op runs. + #[cfg(test)] pub(crate) fn derive_alt_scroll(&mut self) { let role = self.unfocused_split_role(); let rows = self.role_row_count(self.current, role); @@ -3207,6 +3239,35 @@ impl App { derive_scroll_value(self.alt.cursor, self.alt.scroll, rows, self.alt_height); } + /// Bounds-only clamp of the focused pane's scroll — the renderer's per-frame check under + /// the wheel's peek model (CS10). Unlike [`Self::derive_scroll`] it does NOT follow the + /// cursor, so a wheel-scrolled viewport (cursor possibly outside it) survives frames; it + /// only keeps `scroll` inside the row list when a resize/zoom shrinks it. + pub(crate) fn clamp_scroll(&mut self) { + let rows = self.row_count(); + self.scroll = self + .scroll + .min(rows.saturating_sub(self.pane_height.max(1))); + } + + /// [`Self::clamp_scroll`] for the unfocused split pane. + pub(crate) fn clamp_alt_scroll(&mut self) { + let role = self.unfocused_split_role(); + let rows = self.role_row_count(self.current, role); + self.alt.scroll = self + .alt + .scroll + .min(rows.saturating_sub(self.alt_height.max(1))); + } + + /// [`Self::clamp_scroll`] for the outline pane. + pub(crate) fn clamp_outline_scroll(&mut self, rows: usize) { + self.outline.scroll = self + .outline + .scroll + .min(rows.saturating_sub(self.outline_height.max(1))); + } + /// Re-derive the outline pane's `scroll` from its `cursor` — the outline's counterpart to /// [`Self::derive_scroll`], reusing the same [`derive_scroll_value`] core against /// [`Self::outline_height`]. Called after every outline-cursor mutation (mirroring how every @@ -4535,7 +4596,7 @@ mod tests { 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, + SummaryTarget, Zoom, DEFAULT_OUTLINE_WIDTH, SCROLLOFF, }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::ReviewConfig; @@ -10363,9 +10424,9 @@ mod tests { } #[test] - fn wheel_over_the_outline_focuses_it_and_moves_the_cursor_by_delta_with_scrolloff() { + fn wheel_over_the_outline_scrolls_the_viewport_without_moving_cursor_or_diff() { let mut app = four_committed_changesets_three_files_each(); - app.outline_height = 5; // bottom_margin = 5 - 1 - SCROLLOFF(2) = 2 + app.outline_height = 5; app.outline.cursor = 0; app.derive_outline_scroll(app.outline_items().len()); app.hit_regions.outline = Some(Region { @@ -10375,20 +10436,40 @@ mod tests { h: 5, }); assert!(!app.outline_focused()); + let (cs_before, file_before) = (app.current_cs(), app.current); app.handle_wheel(5, 2, 3); assert!(app.outline_focused(), "a wheel event focuses its pane"); - assert_eq!(app.outline_cursor(), 3); - let scroll = app.outline_scroll(); - assert!( - app.outline_cursor() >= scroll && app.outline_cursor() <= scroll + 2, - "the outline scroll must follow the wheel-moved cursor via the normal scrolloff derive" + assert_eq!( + app.outline_scroll(), + 3, + "the wheel moves the VIEWPORT by delta" + ); + assert_eq!( + app.outline_cursor(), + 0, + "peek model: the cursor never moves with the wheel, even out of the viewport" + ); + assert_eq!( + (app.current_cs(), app.current), + (cs_before, file_before), + "no cursor move means no diff jump, ever" + ); + + // The recovery gesture: the next cursor op re-derives the scroll and snaps the view + // back to the (wheel-abandoned) cursor. + app.outline_move_by(1); + assert_eq!(app.outline_cursor(), 1); + assert_eq!( + app.outline_scroll(), + 0, + "a cursor op after a wheel peek snaps the viewport back to the cursor" ); } #[test] - fn wheel_over_the_focused_diff_pane_moves_the_cursor_by_delta() { + fn wheel_over_the_focused_diff_pane_scrolls_the_viewport_and_leaves_the_cursor() { // Same 40-line fixture as the click test above — enough rows that a ±3 wheel move never // clamps against a tiny real diff. let lines: String = (1..=40).map(|n| format!("l{n}\n")).collect(); @@ -10400,7 +10481,7 @@ mod tests { let mut app = app_from_fixture(&fixture); app.open_current(); app.pane_height = 10; - app.cursor = 5; + app.cursor = 8; app.scroll = 0; app.hit_regions.single = Some(Region { x: 0, @@ -10408,13 +10489,24 @@ mod tests { w: 40, h: 10, }); - let cursor_before = app.cursor; app.handle_wheel(10, 3, 3); - assert_eq!(app.cursor, cursor_before + 3); + app.handle_wheel(10, 3, 3); + app.handle_wheel(10, 3, 3); + assert_eq!(app.scroll, 9, "three wheel presses move the viewport 3x3"); + assert_eq!( + app.cursor, 8, + "peek model: the cursor stays put even once the viewport has scrolled past it" + ); - app.handle_wheel(10, 3, -3); - assert_eq!(app.cursor, cursor_before); + // The recovery gesture: any cursor op re-derives and snaps the view back. + app.move_cursor_by(1); + assert_eq!(app.cursor, 9); + assert_eq!( + app.scroll, + 9 - SCROLLOFF, + "a cursor op after a wheel peek snaps the viewport back to the cursor's window" + ); } #[test] diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index f78c38a..9961f07 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -600,7 +600,9 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) app.outline_height = area.height as usize; app.hit_regions.outline = Some(region_from(area)); let items = app.outline_items(); - app.derive_outline_scroll(items.len()); + // Bounds-clamp only — NOT a cursor-following derive: under the wheel's peek model a + // scrolled-away viewport must survive the frame; cursor ops re-derive on their own. + app.clamp_outline_scroll(items.len()); let cursor = app.outline_cursor(); let focused = app.outline_focused(); @@ -1312,8 +1314,10 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t app.alt_height = unfocused_h as usize; app.hit_regions.unstaged = Some(region_from(unstaged_content)); app.hit_regions.staged = Some(region_from(staged_content)); - app.derive_scroll(); - app.derive_alt_scroll(); + // Bounds-clamp only (peek model — see render_outline's identical note); this also brings + // the split arm in line with the Single arm, which never re-derived at render time. + 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); @@ -2917,6 +2921,46 @@ mod tests { ); } + #[test] + fn render_preserves_a_wheel_scrolled_outline_viewport() { + // The peek model's load-bearing render change: `render_outline` bounds-CLAMPS the + // outline scroll instead of re-deriving it from the cursor, so a wheel-scrolled + // viewport (cursor left outside it) survives the frame instead of snapping back. + use crate::app::Region; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + assert!(app.outline_open()); + // A 5-row frame leaves a 3-row outline viewport over this fixture's 4 outline rows + // (2 headers + 2 files): max scroll = 1. + app.outline_height = 3; + app.hit_regions.outline = Some(Region { + x: 0, + y: 1, + w: 34, + h: 3, + }); + let cursor_before = app.outline_cursor(); + + app.handle_wheel(2, 2, 3); // clamps to max scroll = 1 + assert_eq!(app.outline_scroll(), 1, "the wheel scrolled the viewport"); + assert_eq!( + app.outline_cursor(), + cursor_before, + "peek model: the wheel never moves the outline cursor" + ); + + render_once(&mut app, OUTLINE_TEST_WIDTH, 5); + assert_eq!( + app.outline_scroll(), + 1, + "a frame must not re-derive the wheeled scroll back to the cursor" + ); + } + #[test] fn outline_cursor_row_carries_cursor_background_when_focused() { let fixture = FixtureBuilder::new() From 4a58f4a01109546e0f89508926b44584b47cb0c4 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sun, 12 Jul 2026 12:13:12 -0400 Subject: [PATCH 130/203] feat(review): horizontal scroll for long diff lines --- Cargo.lock | 1 + git-workon-review/Cargo.toml | 1 + git-workon-review/src/app.rs | 173 +++++++++++++++++ git-workon-review/src/keymap.rs | 67 ++++++- git-workon-review/src/render.rs | 321 ++++++++++++++++++++++++++++++-- git-workon-review/src/tui.rs | 62 +++++- 6 files changed, 610 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6048ebe..c3e14e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -995,6 +995,7 @@ dependencies = [ "tree-sitter-rust", "tree-sitter-toml-ng", "tree-sitter-typescript", + "unicode-width 0.2.2", ] [[package]] diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index d9e9a40..ce464c5 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -54,6 +54,7 @@ tree-sitter-md.workspace = true tree-sitter-rust.workspace = true tree-sitter-toml-ng.workspace = true tree-sitter-typescript.workspace = true +unicode-width.workspace = true [package.metadata.dist] # Redundant with publish = false today; load-bearing at the M3 flip so diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 91e5d77..2735ffe 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -13,6 +13,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::Path; use git2::Repository; +use unicode_width::UnicodeWidthStr; use workon::{Changeset, ChangesetSpan}; use crate::acquire::{ChangesetDiff, WorktreeDiffs}; @@ -40,6 +41,10 @@ use crate::wordiff::{word_diff_spans, Span}; /// [`App::derive_scroll`]. const SCROLLOFF: usize = 2; +/// Display columns panned per `hscroll-left`/`hscroll-right` press — see [`App::hscroll_left`]/ +/// [`App::hscroll_right`]. +const HSCROLL_STEP: usize = 8; + /// Loaded, aligned, highlighted view of one file's combined diff. /// /// Full text is read once per side, from whichever source the file's status says still exists: @@ -1118,6 +1123,13 @@ pub struct App { /// directly by the renderer, but never written except by [`Self::derive_scroll`] — every /// cursor-moving method ends by calling it, so `scroll` always reflects the CURRENT `cursor`. pub scroll: usize, + /// Column pan offset (display columns, not bytes) applied to every diff CONTENT pane — both + /// side-by-side halves and both split panes share this one offset; the gutter stays pinned at + /// column 0. Panned by [`Self::hscroll_left`]/[`Self::hscroll_right`], clamped against the + /// current view's longest row (see those methods), and reset to `0` on file/changeset + /// navigation ([`Self::next_file`]/[`Self::prev_file`]/[`Self::next_changeset`]/ + /// [`Self::prev_changeset`]) — cursor movement within a file leaves it untouched. + pub hscroll: usize, /// Content height of the focused pane, written by the renderer each frame. In a single-pane /// zoom this is the whole body; in a split it's the focused half (see [`Self::alt_height`]). pub pane_height: usize, @@ -1378,6 +1390,7 @@ impl App { current: 0, cursor: 0, scroll: 0, + hscroll: 0, pane_height: 20, alt: PaneState::default(), alt_height: 20, @@ -2359,6 +2372,7 @@ impl App { /// outline-initiated jump (which sets [`OutlineState::cursor`] itself before calling /// `switch_changeset`/`goto_changeset` directly) never re-triggers it. pub fn next_file(&mut self) { + self.hscroll = 0; if self.cur().diff.files.is_empty() { return; } @@ -2378,6 +2392,7 @@ impl App { /// first changeset. See [`Self::next_file`]'s doc comment for why this calls /// [`Self::sync_outline_to_current`] at the end. pub fn prev_file(&mut self) { + self.hscroll = 0; if self.cur().diff.files.is_empty() { return; } @@ -2406,6 +2421,7 @@ impl App { /// DIFF-initiated entry point — see [`Self::next_file`]'s doc comment on the sync-follow /// discipline. pub fn next_changeset(&mut self) { + self.hscroll = 0; if self.current_cs + 1 < self.changesets.len() { self.goto_changeset(self.current_cs + 1); } @@ -2415,6 +2431,7 @@ impl App { /// Jump to the previous changeset's first file (`[c`). A no-op at the first changeset. See /// [`Self::next_file`]'s doc comment on the sync-follow discipline. pub fn prev_changeset(&mut self) { + self.hscroll = 0; if self.current_cs > 0 { self.goto_changeset(self.current_cs - 1); } @@ -3268,6 +3285,59 @@ impl App { .min(rows.saturating_sub(self.outline_height.max(1))); } + /// The widest display-column row currently in the active file's view(s) — both roles when + /// split, since [`Self::hscroll`] pans every content pane together (locked decision #1). + /// Walks the already-built [`FileView::display`] row list (shared by both the SBS and inline + /// layouts — inline just re-derives its own row list from the same text), so this is a pure + /// lookup over rows the renderer rebuilds every frame anyway, not a fresh scan of the file. + /// Used only by [`Self::clamp_hscroll`] to keep at least one column of the longest line + /// reachable; computed on demand rather than cached (cheap — see that method's doc comment). + fn max_row_width(&self) -> usize { + let idx = self.current; + let roles: Vec = match self.effective_zoom_for(idx) { + EffectiveZoom::Single(role) => vec![role], + EffectiveZoom::Split => vec![Role::Unstaged, Role::Staged], + }; + let mut max = 0; + for role in roles { + let Some(view) = self.role_view_ref(idx, role) else { + continue; + }; + for row in &view.display { + let DisplayRow::Row(r) = row else { continue }; + if let Row::Line(n) = r.old { + max = max.max(UnicodeWidthStr::width(view.old_line(n))); + } + if let Row::Line(n) = r.new { + max = max.max(UnicodeWidthStr::width(view.new_line(n))); + } + } + } + max + } + + /// Clamp [`Self::hscroll`] into `[0, max_row_width().saturating_sub(1)]` — the `-1` keeps at + /// least one column of the longest line visible (locked decision #4) rather than letting the + /// pan run all the way to a blank viewport. + fn clamp_hscroll(&mut self) { + let max = self.max_row_width().saturating_sub(1); + self.hscroll = self.hscroll.min(max); + } + + /// `hscroll-left`: pan the diff content panes left by [`HSCROLL_STEP`] columns (floored at + /// `0`). + pub fn hscroll_left(&mut self) { + self.hscroll = self.hscroll.saturating_sub(HSCROLL_STEP); + } + + /// `hscroll-right`: pan the diff content panes right by [`HSCROLL_STEP`] columns, clamped so + /// at least one column of the current view's longest row stays visible (see + /// [`Self::clamp_hscroll`]). + pub fn hscroll_right(&mut self) { + self.hscroll = self.hscroll.saturating_add(HSCROLL_STEP); + self.clamp_hscroll(); + } + /// Re-derive the outline pane's `scroll` from its `cursor` — the outline's counterpart to /// [`Self::derive_scroll`], reusing the same [`derive_scroll_value`] core against /// [`Self::outline_height`]. Called after every outline-cursor mutation (mirroring how every @@ -4666,6 +4736,62 @@ mod tests { assert_eq!(view.new_text(), ""); } + // ── diff-hscroll: pan clamping ────────────────────────────────────────────── + + #[test] + fn hscroll_left_floors_at_zero() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert_eq!(app.hscroll, 0); + app.hscroll_left(); + assert_eq!(app.hscroll, 0, "cannot pan left of column 0"); + } + + #[test] + fn hscroll_right_clamps_to_the_longest_row_leaving_one_column_visible() { + // A line well over a terminal width, so repeated `hscroll-right` presses hit the clamp + // rather than running out of steps first. + let long_line = "x".repeat(200); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "short\n", &format!("{long_line}\n")) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + for _ in 0..100 { + app.hscroll_right(); + } + // `max_row_width` is 200 (the long line); the clamp keeps one column of it reachable. + assert_eq!(app.hscroll, 199); + } + + #[test] + fn hscroll_right_on_a_file_with_no_long_rows_clamps_to_zero() { + // Every row is a single column wide, so `max_row_width` (1) leaves nothing to pan into — + // the clamp (`max_row_width - 1`) is `0`. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "a\n", "a\nb\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.hscroll_right(); + assert_eq!( + app.hscroll, 0, + "every row already fits, so there is nothing to pan into" + ); + } + #[test] fn ensure_loaded_reads_old_path_for_renamed_file() { let fixture = FixtureBuilder::new() @@ -7916,6 +8042,53 @@ mod tests { assert_eq!(app.current, 0); } + // ── diff-hscroll: reset on file/changeset nav, preserved across cursor movement ────── + + #[test] + fn next_file_resets_hscroll_to_zero() { + let mut app = two_committed_changesets_two_and_one_files(); + app.hscroll = 5; + app.next_file(); + assert_eq!(app.hscroll, 0); + } + + #[test] + fn prev_file_resets_hscroll_to_zero() { + let mut app = two_committed_changesets_two_and_one_files(); + app.goto_changeset(1); + app.hscroll = 5; + app.prev_file(); + assert_eq!(app.hscroll, 0); + } + + #[test] + fn next_changeset_resets_hscroll_to_zero() { + let mut app = two_committed_changesets_two_and_one_files(); + app.hscroll = 5; + app.next_changeset(); + assert_eq!(app.hscroll, 0); + } + + #[test] + fn prev_changeset_resets_hscroll_to_zero() { + let mut app = two_committed_changesets_two_and_one_files(); + app.goto_changeset(1); + app.hscroll = 5; + app.prev_changeset(); + assert_eq!(app.hscroll, 0); + } + + #[test] + fn cursor_movement_within_a_file_preserves_hscroll() { + let mut app = two_committed_changesets_two_and_one_files(); + app.hscroll = 5; + app.move_cursor_by(1); + assert_eq!( + app.hscroll, 5, + "plain cursor movement must not reset the horizontal pan" + ); + } + /// Regression: navigating to an OLDER committed changeset and loading its combined view must /// source the new side from that changeset's `head` commit tree, not the current worktree. The /// same file `f.txt` is touched by both changesets, so `cs-a`'s head (`mid`) content differs diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 616c9eb..badd23f 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -64,6 +64,8 @@ pub enum Command { PrevChangeset, ExpandGap, ExpandGapAll, + HscrollLeft, + HscrollRight, // Diff view. FocusOutline, // Outline view. @@ -273,7 +275,21 @@ pub static REGISTRY: &[Registered] = &[ view: View::Diff, name: "focus-outline", default_keys: "h left", - description: "Focus the outline", + description: "Focus the outline (pans the diff back to column 0 first, if panned)", + }, + Registered { + command: Command::HscrollLeft, + view: View::Diff, + name: "hscroll-left", + default_keys: "<", + description: "Pan the diff content left", + }, + Registered { + command: Command::HscrollRight, + view: View::Diff, + name: "hscroll-right", + default_keys: "> l right", + description: "Pan the diff content right", }, Registered { command: Command::ExpandGap, @@ -998,6 +1014,55 @@ mod tests { ); } + // ── diff-hscroll: `hscroll-left`/`hscroll-right` registry rows ───────────── + + #[test] + fn hscroll_commands_are_registered_with_no_collision_warnings() { + let km = Keymap::defaults(); + assert!( + km.warnings().is_empty(), + "the new commands' defaults must not collide with anything: {:?}", + km.warnings() + ); + assert!( + !km.keys_for(Command::HscrollLeft).is_empty(), + "hscroll-left must resolve to at least one bound key" + ); + assert!( + !km.keys_for(Command::HscrollRight).is_empty(), + "hscroll-right must resolve to at least one bound key" + ); + } + + #[test] + fn less_than_and_greater_than_dispatch_hscroll_in_the_diff_view() { + let km = Keymap::defaults(); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('<'))]), + Dispatch::Command(Command::HscrollLeft) + ); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('>'))]), + Dispatch::Command(Command::HscrollRight) + ); + } + + /// `l`/`right` are free in the Diff view (they're only bound in the Outline view, to + /// `focus-diff`) — the handoff's locked decision #2 reuses them for `hscroll-right` there, + /// mirroring the Outline view's `l`/`right` = focus-diff. + #[test] + fn l_and_right_dispatch_hscroll_right_in_the_diff_view() { + let km = Keymap::defaults(); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('l'))]), + Dispatch::Command(Command::HscrollRight) + ); + assert_eq!( + feed(&km, false, &[key(KeyCode::Right)]), + Dispatch::Command(Command::HscrollRight) + ); + } + #[test] fn a_config_rebind_overrides_the_default() { let km = Keymap::from_bindings(&[RawBinding { diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 9961f07..d76f786 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -11,6 +11,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span as TSpan}; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; +use unicode_width::UnicodeWidthChar; use crate::align::{CellKind, DisplayRow, InlineRow, Row}; use crate::app::{ @@ -193,6 +194,30 @@ fn apply_selection_row(line: Line<'static>, width: u16, theme: &Palette) -> Line apply_row_tint(line, width, theme.selection_bg) } +/// Horizontal-scroll right-edge marker (decision #7): if `line` (as already blitted into `area` +/// by the caller's `set_line`) is wider than `area`'s content width, overwrite the pane's last +/// cell with a dim `…` so a panned-right line still signals there's more to the right. Applied +/// AFTER `set_line` (and after any cursor/selection wash, which paints its own background first) +/// so the marker survives on a cursor row — `Buffer::set_string`'s `Cell::set_style` only +/// overwrites `fg` when the given style sets it (leaves `bg` untouched when it doesn't, per +/// ratatui's `Style::patch` semantics), so this only ever changes the glyph + foreground, never +/// erasing the wash underneath. +fn apply_right_edge_marker( + buf: &mut Buffer, + area: Rect, + y: u16, + line: &Line<'static>, + theme: &Palette, +) { + if area.width == 0 { + return; + } + if line.width() > area.width as usize { + let x = area.x + area.width - 1; + buf.set_string(x, y, HSCROLL_MARKER, Style::default().fg(theme.dim)); + } +} + /// One resolved (bg, fg) pair for a byte range of a line. struct Segment { start: usize, @@ -344,6 +369,40 @@ enum Side { New, } +/// Horizontal-scroll left-edge marker (decision #7): replaces the first visible content column +/// whenever a line actually had content panned off to the left. Dim-styled like the gap-row/ +/// filler markers — no new color, just `theme.dim` on the existing `…` glyph. +const HSCROLL_MARKER: &str = "…"; + +/// Find the byte offset that cuts `text` at display column `col` (0 for `col == 0`), for +/// [`content_spans`]'s horizontal-scroll slicing. Column, not byte, is the unit `App::hscroll` +/// counts in, so this walks chars accumulating [`UnicodeWidthChar`] widths rather than indexing +/// `text` directly — indexing by column count would panic on a non-char-boundary byte offset for +/// any multibyte UTF-8 line. +/// +/// Returns `(byte_offset, pad)`: `pad` is `true` when a wide (2-column) char straddles the cut — +/// e.g. `col` lands mid-CJK-glyph — in which case that char is dropped entirely (skipping it +/// half-visible would misalign every column after it) and the caller should prepend a one-column +/// space to keep alignment. `col` at or beyond the line's total width returns `(text.len(), false)` +/// (nothing left to show). +fn hscroll_cut(text: &str, col: usize) -> (usize, bool) { + if col == 0 { + return (0, false); + } + let mut acc = 0usize; + for (i, c) in text.char_indices() { + if acc >= col { + return (i, false); + } + let w = UnicodeWidthChar::width(c).unwrap_or(0); + if acc + w > col { + return (i + c.len_utf8(), true); + } + acc += w; + } + (text.len(), false) +} + /// Build the styled content spans (everything after the gutter) for one line of text, shared by /// [`build_pane_line`] (SBS) and [`build_inline_line`] (inline) — the two differ only in how they /// resolve `text`/`hl`/`emphasis` from a [`Row`] vs an [`InlineRow`] and in their gutter, not in @@ -352,6 +411,15 @@ enum Side { /// `emphasis` is `Some((subtle, strong))` for a `Del`/`Add` line (whole-line subtle background, /// plus per-`word_spans` strong background when `is_word_pair`; whole-line strong when not paired /// — an unpaired excess line) and `None` for `Context`/`Filler` (no background emphasis at all). +/// +/// `hscroll` (display columns, [`App::hscroll`]) pans the returned spans: segments are composed +/// over the FULL, unsliced `text` exactly as before (every span offset below stays byte-based), +/// then trimmed to start at `hscroll`'s cut point (decision #6) — dropping a segment entirely if +/// it ends at or before the cut, else re-slicing its tail. When the cut actually removed content +/// (`hscroll > 0` and something preceded it), the first visible column renders [`HSCROLL_MARKER`] +/// instead (decision #7's left-edge affordance) — the real cut point in that case is one column +/// further right, to make room for the marker. +#[allow(clippy::too_many_arguments)] fn content_spans( text: &str, hl: Option<&Vec>, @@ -359,6 +427,7 @@ fn content_spans( word_spans: &[WordSpan], is_word_pair: bool, theme: &Palette, + hscroll: usize, ) -> Vec> { let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); if let Some((subtle_bg, strong_bg)) = emphasis { @@ -374,19 +443,51 @@ fn content_spans( } let segments = compose_segments(text.len(), &bg_spans, hl, theme); - let mut spans = Vec::with_capacity(segments.len().max(1)); - if segments.is_empty() && !text.is_empty() { + + // Nothing panned off yet: the common case, byte-identical to pre-hscroll behavior. + let (cut, pad, marker) = if hscroll == 0 { + (0, false, false) + } else { + let (base_cut, _) = hscroll_cut(text, hscroll); + if base_cut > 0 { + // Something was actually cut — reserve column `hscroll` for the marker by cutting + // one column further in. + let (marker_cut, pad) = hscroll_cut(text, hscroll + 1); + (marker_cut, pad, true) + } else { + (0, false, false) + } + }; + + let mut spans = Vec::with_capacity(segments.len().max(1) + 2); + if marker { + spans.push(TSpan::styled( + HSCROLL_MARKER.to_string(), + Style::default().fg(theme.dim), + )); + } + if pad { + spans.push(TSpan::styled( + " ".to_string(), + Style::default().fg(theme.foreground), + )); + } + if segments.is_empty() && !text.is_empty() && cut < text.len() { spans.push(TSpan::styled( - text.to_string(), + text[cut..].to_string(), Style::default().fg(theme.foreground), )); } for seg in segments { + if seg.end <= cut { + continue; + } + let start = seg.start.max(cut); let mut style = Style::default().fg(seg.fg); if let Some(bg) = seg.bg { style = style.bg(bg); } - spans.push(TSpan::styled(text[seg.start..seg.end].to_string(), style)); + spans.push(TSpan::styled(text[start..seg.end].to_string(), style)); } spans } @@ -404,6 +505,7 @@ fn build_pane_line( gutter_w: usize, content_w: usize, theme: &Palette, + hscroll: usize, ) -> Line<'static> { match row { Row::Filler => { @@ -436,6 +538,7 @@ fn build_pane_line( word_spans, is_word_pair, theme, + hscroll, )); Line::from(spans) } @@ -801,14 +904,29 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { let idx = app.current + 1; let n = app.files().len(); let text = format!("[{idx}/{n}] {}", current_file_label(app)); - frame.render_widget( - Paragraph::new(text).style( - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), - ), - area, - ); + 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 +/// present-or-absent pattern above/below. +fn hscroll_indicator_span(app: &App, theme: &Palette) -> Option> { + if app.hscroll == 0 { + return None; + } + Some(TSpan::styled( + format!(" »{}", app.hscroll), + Style::default().fg(theme.dim), + )) } /// The multi-changeset winbar (locked decisions #8 + #9): `[i/n] @@ -897,6 +1015,9 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { .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); } @@ -1417,6 +1538,9 @@ fn render_pane_sbs( let old_area = hlayout[0]; let div_area = hlayout[1]; let new_area = hlayout[2]; + // One offset shared by every content pane (locked decision #1) — read once, before any of + // the `app` borrows below. + let hscroll = app.hscroll; let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), old_area); @@ -1492,6 +1616,7 @@ fn render_pane_sbs( old_gutter_w, old_area.width as usize, theme, + hscroll, ); let new_line = build_pane_line( view, @@ -1504,6 +1629,7 @@ fn render_pane_sbs( new_gutter_w, new_area.width as usize, theme, + hscroll, ); // Cursor wins over selection on the same row (see [`Palette::selection_bg`]). let (old_line, new_line) = if is_cursor { @@ -1525,6 +1651,12 @@ fn render_pane_sbs( frame .buffer_mut() .set_line(new_area.x, y, &new_line, new_area.width); + // Right-edge hscroll marker (decision #7) — applied AFTER `set_line` (and thus + // after the cursor/selection wash above, which already painted the background) + // so it survives on a cursor/selected row; `apply_right_edge_marker` only sets + // `fg`, leaving whatever background the wash left in place. + apply_right_edge_marker(frame.buffer_mut(), old_area, y, &old_line, theme); + apply_right_edge_marker(frame.buffer_mut(), new_area, y, &new_line, theme); // 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`. @@ -1557,6 +1689,7 @@ fn gutter_field(n: Option, w: usize) -> String { /// rows show only the old-side column, `Add` rows only the new-side column — the other column is /// blank rather than reused for anything, so a scan down the gutter reads as two honest, /// independent line-number tracks. +#[allow(clippy::too_many_arguments)] fn build_inline_line( view: &FileView, row: &InlineRow, @@ -1565,6 +1698,7 @@ fn build_inline_line( old_gutter_w: usize, new_gutter_w: usize, theme: &Palette, + hscroll: usize, ) -> Line<'static> { let (old_opt, new_opt, text, hl, kind) = match *row { InlineRow::Context { old, new } => ( @@ -1615,6 +1749,7 @@ fn build_inline_line( word_spans, is_word_pair, theme, + hscroll, )); Line::from(spans) } @@ -1634,6 +1769,10 @@ fn render_pane_inline( selection: Option<(usize, usize)>, theme: &Palette, ) { + // One offset shared by every content pane (locked decision #1) — read once, before any of + // the `app` borrows below. + let hscroll = app.hscroll; + let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), area); return; @@ -1695,6 +1834,7 @@ fn render_pane_inline( old_gutter_w, new_gutter_w, theme, + hscroll, ); // Cursor wins over selection on the same row (see [`Palette::selection_bg`]). let line = if is_cursor { @@ -1705,6 +1845,9 @@ fn render_pane_inline( line }; frame.buffer_mut().set_line(area.x, y, &line, area.width); + // Right-edge hscroll marker (decision #7) — see `render_pane_sbs`'s identical + // comment on ordering relative to the cursor/selection wash above. + apply_right_edge_marker(frame.buffer_mut(), area, y, &line, theme); } } } @@ -1717,8 +1860,9 @@ mod tests { use ratatui::Terminal; use git_workon_fixture::prelude::*; + use unicode_width::UnicodeWidthChar; - use super::render; + use super::{hscroll_cut, render}; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; use crate::app::App; @@ -2806,6 +2950,157 @@ mod tests { ); } + // ── diff-hscroll ───────────────────────────────────────────────────────────── + + #[test] + fn hscroll_cut_ascii() { + // "hello world" — cutting at column 6 lands right after the space, before "world". + assert_eq!(hscroll_cut("hello world", 6), (6, false)); + assert_eq!(hscroll_cut("hello world", 0), (0, false)); + } + + #[test] + fn hscroll_cut_multibyte_narrow() { + // "café" — 'é' is a single (narrow, non-ASCII) column, so cutting at column 3 lands + // exactly at its 2-byte UTF-8 start. + let text = "café"; + assert_eq!(UnicodeWidthChar::width('é'), Some(1)); + let (cut, pad) = hscroll_cut(text, 3); + assert_eq!(&text[cut..], "é"); + assert!(!pad); + } + + #[test] + fn hscroll_cut_wide_cjk_straddling_the_cut_skips_it_and_pads() { + // "a漢b" — 'a' (col 0), '漢' (cols 1-2, a wide CJK glyph), 'b' (col 3). Cutting at column + // 2 lands mid-glyph: the whole wide char is dropped and `pad` signals the caller to + // insert a one-column space to keep the remaining columns aligned. + let text = "a漢b"; + assert_eq!(UnicodeWidthChar::width('漢'), Some(2)); + let (cut, pad) = hscroll_cut(text, 2); + assert!( + pad, + "a wide char straddling the cut must request a pad column" + ); + assert_eq!(&text[cut..], "b"); + } + + #[test] + fn hscroll_cut_emoji() { + // Most terminal-emulator-relevant emoji are wide (2 columns), like CJK. + let text = "a🎉b"; + let w = UnicodeWidthChar::width('🎉').unwrap_or(0); + let (cut, _pad) = hscroll_cut(text, 1 + w); + assert_eq!(&text[cut..], "b"); + } + + #[test] + fn hscroll_cut_beyond_line_width_yields_empty() { + let (cut, pad) = hscroll_cut("short", 100); + assert_eq!(cut, "short".len()); + assert!(!pad); + assert_eq!(&"short"[cut..], ""); + } + + /// Build a single unstaged-file `App` with one long line, for the hscroll rendering tests — + /// long enough that panning by [`crate::app::HSCROLL_STEP`]-sized steps has real room to move + /// (the tests don't reference that constant directly since it's private to `app.rs`; `200` + /// just needs to comfortably exceed a test pane's width either way). + fn app_with_a_long_line() -> App { + let long_line = "x".repeat(200); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("long.txt", "short\n", &format!("{long_line}\n")) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app + } + + #[test] + fn panning_right_shows_the_left_edge_marker_and_shifted_content() { + let mut app = app_with_a_long_line(); + app.hscroll_right(); + assert!( + app.hscroll > 0, + "the long line must give hscroll room to pan" + ); + + let buf = render_once(&mut app, 60, 20); + // divider (1) + new-side gutter ("{n:>3} ", 4 chars) — the new pane's first content + // column. + let left_w = buf.area.width.saturating_sub(1) / 2; + let content_x = left_w + 1 + 4; + let row_y = (0..buf.area.height) + .find(|&y| cell_text(&buf, content_x, y) == "…") + .expect("the panned long line's first visible content column must show the marker"); + assert_eq!( + cell_text(&buf, content_x + 1, row_y), + "x", + "content immediately after the marker must be the (shifted) line body" + ); + } + + #[test] + fn a_line_wider_than_the_pane_shows_the_right_edge_marker() { + let mut app = app_with_a_long_line(); + // At `hscroll == 0` the long line already overflows a narrow pane's content width. + assert_eq!(app.hscroll, 0); + + let buf = render_once(&mut app, 60, 20); + let right_x = buf.area.width - 1; + assert!( + (0..buf.area.height).any(|y| cell_text(&buf, right_x, y) == "…"), + "a line wider than the pane must show the right-edge marker" + ); + } + + #[test] + fn winbar_shows_the_pan_offset_indicator_once_panned() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + assert_eq!(app.hscroll, 0); + + let buf_unpanned = render_once(&mut app, 80, 20); + let header_unpanned: String = (0..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:?}" + ); + + // The winbar test's 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(); + assert!( + header.contains("»42"), + "expected the pan offset indicator, got: {header:?}" + ); + } + + #[test] + fn single_changeset_header_shows_the_pan_offset_indicator_once_panned() { + let mut app = app_with_a_long_line(); + app.hscroll_right(); + + 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(&format!("»{}", app.hscroll)), + "expected the pan offset indicator on the lone-changeset header, got: {header:?}" + ); + } + // ── M5 CS3: outline side pane ─────────────────────────────────────────────── /// Every outline test renders at this width so the pane's fixed 35-col + 1-col-divider diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 9199a01..f710b1c 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -444,6 +444,8 @@ enum Action { StartSelection, ExpandGap, ExpandGapAll, + HscrollLeft, + HscrollRight, ToggleOutline, OutlineMoveBy(i64), OutlineConfirm, @@ -484,6 +486,8 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::StartSelection => Action::StartSelection, Command::ExpandGap => Action::ExpandGap, Command::ExpandGapAll => Action::ExpandGapAll, + Command::HscrollLeft => Action::HscrollLeft, + Command::HscrollRight => Action::HscrollRight, Command::NextFile => Action::NextFile, Command::PrevFile => Action::PrevFile, Command::NextHunk => Action::NextHunk, @@ -611,11 +615,25 @@ 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::HscrollLeft => app.hscroll_left(), + Action::HscrollRight => app.hscroll_right(), Action::ToggleOutline => app.toggle_outline(), Action::OutlineMoveBy(delta) => app.outline_move_by(delta), Action::OutlineConfirm => app.outline_confirm(), Action::OutlineCycleMode => app.outline_cycle_mode(), - Action::FocusOutline => app.focus_outline(), + // `h`/`left` pans the diff back to column 0 first (mirroring the outline's own home + // position) and only actually focuses the outline once there — see the handoff's locked + // decision #2. Implemented here rather than in `App::focus_outline` itself, since that + // method is also called from the outline toggle (`App::toggle_outline`) and the mouse + // click/wheel paths (`App::handle_click`/`handle_wheel`), none of which should gain pan + // behavior. + Action::FocusOutline => { + if app.hscroll > 0 { + app.hscroll_left(); + } else { + app.focus_outline(); + } + } Action::FocusDiff => app.focus_diff(), Action::OutlineTop => app.outline_top(), Action::OutlineBottom => app.outline_bottom(), @@ -3235,4 +3253,46 @@ mod tests { "the active file's view must be cached after its FileReady lands" ); } + + // ── diff-hscroll: `Action::FocusOutline` pans home before focusing ───────────── + + /// Locked decision #2: `h`/`left` (`Action::FocusOutline`) pans the diff back toward column + /// `0` first while panned, and only actually focuses the outline once there — implemented in + /// this dispatch arm rather than in `App::focus_outline` itself (see that arm's comment), so + /// this is only testable at the `apply_action` layer, not through `App` alone. + #[test] + fn focus_outline_action_pans_home_before_focusing_when_panned() { + use git_workon_fixture::prelude::*; + + let long_line = "x".repeat(200); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "short\n", &format!("{long_line}\n")) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.hscroll_right(); + assert!( + app.hscroll > 0, + "the long line must give hscroll room to pan" + ); + assert!(!app.outline_focused()); + + // Panned: the first press pans back toward column 0 rather than focusing the outline. + apply_action(&mut app, Action::FocusOutline); + assert_eq!( + app.hscroll, 0, + "one press from a single hscroll step returns to column 0" + ); + assert!( + !app.outline_focused(), + "still unfocused — this press only panned" + ); + + // Already at column 0: the next press focuses the outline as normal. + apply_action(&mut app, Action::FocusOutline); + assert!(app.outline_focused()); + } } From cfc371517a09657f58a2053811231e84424efb1a Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sun, 12 Jul 2026 13:20:31 -0400 Subject: [PATCH 131/203] feat(review): mouse h-wheel panning and outline hscroll --- git-workon-review/src/app.rs | 241 +++++++++++++++++++++++++++- git-workon-review/src/keymap.rs | 16 ++ git-workon-review/src/render.rs | 268 ++++++++++++++++++++++++++------ git-workon-review/src/tui.rs | 83 +++++++++- 4 files changed, 556 insertions(+), 52 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 2735ffe..1afc0f4 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -805,6 +805,14 @@ pub struct OutlineState { /// Which end of the stack the stack-shaped modes display first — `workon.review.outline.order` /// (CS3), defaulting to [`OutlineOrder::HeadFirst`]. Read by [`App::outline_items`]. pub order: OutlineOrder, + /// Column pan offset (display columns) for the outline pane — the outline's own analog of + /// [`App::hscroll`], since a long path is hard-clipped at the outline's fixed width just like + /// a long diff line. Floored at `0` by [`App::outline_hscroll_left`]/ + /// [`App::outline_hscroll_right`]; the upper clamp is render-side (`render_outline`, mirroring + /// [`App::clamp_outline_scroll`]'s own per-frame bounds-clamp under the wheel peek model), not + /// here. Reset to `0` by [`App::outline_cycle_mode`] — the row list (and therefore the set of + /// paths on screen) changes shape there, the same reason that resyncs the cursor. + pub hscroll: usize, } /// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the @@ -1369,6 +1377,7 @@ impl App { width: DEFAULT_OUTLINE_WIDTH, scroll: 0, order: OutlineOrder::default(), + hscroll: 0, }; let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial @@ -2571,6 +2580,14 @@ impl App { self.outline.scroll } + /// The outline pane's column pan offset — see [`OutlineState::hscroll`]'s doc comment. Read + /// by `render.rs`'s `render_outline`, which also owns the render-side upper clamp (mirroring + /// [`Self::clamp_outline_scroll`]'s own per-frame bounds-clamp) via + /// [`Self::clamp_outline_hscroll`]. + pub fn outline_hscroll(&self) -> usize { + self.outline.hscroll + } + /// The outline pane's column width — `workon.review.outline.width` (CS7), or /// [`DEFAULT_OUTLINE_WIDTH`] if never set. Read by `render.rs` in place of the old fixed /// const. @@ -2740,6 +2757,49 @@ impl App { } } + /// Horizontal mouse wheel (trackpad h-scroll, or a shift-wheel the terminal reports as + /// `ScrollLeft`/`ScrollRight`) at terminal `(col, row)` with `delta` = ±4 columns per tick + /// (`tui::map_key`'s caller maps `ScrollLeft`/`ScrollRight` to -4/+4 — finer than + /// [`HSCROLL_STEP`] since trackpads emit streams of ticks). Same peek-model framing and + /// region-focus rule as [`Self::handle_wheel`] — the difference is WHAT gets panned: unlike + /// the vertical wheel (which always scrolls whichever pane's own row-list viewport), this + /// pans a COLUMN offset shared per PANE KIND — the outline's own `outline.hscroll` over the + /// outline, or the diff panes' shared [`Self::hscroll`] over a diff pane (both halves of a + /// split share the one offset, same as [`Self::hscroll_left`]/[`Self::hscroll_right`]). + /// Outside every recorded region: no-op. + pub fn handle_hwheel(&mut self, col: u16, row: u16, delta: i64) { + let Some((pane, _region)) = self.hit_test(col, row) else { + return; + }; + match pane { + HitPane::Outline => { + self.focus_outline(); + self.outline.hscroll = (self.outline.hscroll as i64 + delta).max(0) as usize; + // No upper clamp here — render-side, mirroring `outline_hscroll_right`'s own + // doc comment. + } + HitPane::Single => { + self.focus_diff_pane(None); + self.pan_hscroll_by(delta); + } + HitPane::Split(target) => { + self.focus_diff_pane(Some(target)); + self.pan_hscroll_by(delta); + } + } + } + + /// Pan the shared diff [`Self::hscroll`] by `delta` columns (floored at `0`), clamping + /// against the current view's longest row on a RIGHTWARD pan only — the same clamp + /// [`Self::hscroll_right`] applies, factored out here so [`Self::handle_hwheel`] doesn't + /// clamp a leftward pan against a bound that only matters when panning right. + fn pan_hscroll_by(&mut self, delta: i64) { + self.hscroll = (self.hscroll as i64 + delta).max(0) as usize; + if delta > 0 { + self.clamp_hscroll(); + } + } + /// Scroll the focused pane's viewport by `delta` rows (mouse wheel), clamped to the row /// list. The cursor is deliberately NOT touched (the peek model: a wheel is "look /// elsewhere", never "select elsewhere"), so it can sit outside the viewport — the next @@ -2778,9 +2838,12 @@ impl App { /// `i` while the outline has focus: cycle [`OutlineMode`], then reposition the cursor onto /// the row matching the current diff position in the NEW mode's row list (the row layout - /// just changed shape, so the raw index would otherwise point at an unrelated row). + /// just changed shape, so the raw index would otherwise point at an unrelated row). Also + /// resets [`OutlineState::hscroll`] to `0` — the row list's shape (and therefore its longest + /// path) just changed too, so a stale pan offset could easily land past the new mode's content. pub fn outline_cycle_mode(&mut self) { self.outline.mode = self.outline.mode.cycle(); + self.outline.hscroll = 0; self.sync_outline_to_current(); } @@ -3285,6 +3348,17 @@ impl App { .min(rows.saturating_sub(self.outline_height.max(1))); } + /// Render-side upper clamp for [`OutlineState::hscroll`] — the outline analog of + /// [`Self::clamp_hscroll`], but taken from the caller rather than computed here: + /// `render_outline` already builds every item's line to paint it, so it's cheaper for it to + /// pass the max width it just measured than for this method to rebuild the whole outline a + /// second time. `max_line_width` is the widest rendered outline row's display-column width; + /// the `-1` keeps at least one column of the longest row visible, same as + /// [`Self::clamp_hscroll`]. + pub(crate) fn clamp_outline_hscroll(&mut self, max_line_width: usize) { + self.outline.hscroll = self.outline.hscroll.min(max_line_width.saturating_sub(1)); + } + /// The widest display-column row currently in the active file's view(s) — both roles when /// split, since [`Self::hscroll`] pans every content pane together (locked decision #1). /// Walks the already-built [`FileView::display`] row list (shared by both the SBS and inline @@ -3338,6 +3412,22 @@ impl App { self.clamp_hscroll(); } + /// `outline-hscroll-left`: pan the outline pane left by [`HSCROLL_STEP`] columns (floored at + /// `0`) — the outline's own analog of [`Self::hscroll_left`]. + pub fn outline_hscroll_left(&mut self) { + self.outline.hscroll = self.outline.hscroll.saturating_sub(HSCROLL_STEP); + } + + /// `outline-hscroll-right`: pan the outline pane right by [`HSCROLL_STEP`] columns. Unlike + /// [`Self::hscroll_right`] this has NO upper clamp here — the outline's row list (every + /// item's rendered line, built by `render.rs`'s `build_outline_line`) isn't cheaply available + /// to `App` the way a [`FileView`]'s rows are, so the clamp is render-side instead + /// (`render::render_outline`, mirroring how [`Self::clamp_outline_scroll`] already + /// bounds-clamps `outline.scroll` once per frame under the wheel peek model). + pub fn outline_hscroll_right(&mut self) { + self.outline.hscroll = self.outline.hscroll.saturating_add(HSCROLL_STEP); + } + /// Re-derive the outline pane's `scroll` from its `cursor` — the outline's counterpart to /// [`Self::derive_scroll`], reusing the same [`derive_scroll_value`] core against /// [`Self::outline_height`]. Called after every outline-cursor mutation (mirroring how every @@ -4666,7 +4756,7 @@ mod tests { 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, SCROLLOFF, + SummaryTarget, Zoom, DEFAULT_OUTLINE_WIDTH, HSCROLL_STEP, SCROLLOFF, }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::ReviewConfig; @@ -8491,6 +8581,38 @@ mod tests { ); } + #[test] + fn outline_cycle_mode_resets_outline_hscroll() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.hscroll = 5; + app.outline_cycle_mode(); + assert_eq!( + app.outline_hscroll(), + 0, + "a mode cycle reshapes the row list, so a stale pan offset must reset" + ); + } + + #[test] + fn outline_hscroll_left_floors_at_zero() { + let mut app = two_committed_changesets_two_and_one_files(); + assert_eq!(app.outline_hscroll(), 0); + app.outline_hscroll_left(); + assert_eq!(app.outline_hscroll(), 0, "cannot pan left of column 0"); + } + + #[test] + fn outline_hscroll_right_has_no_upper_clamp_in_the_method_itself() { + // Locked decision #2: `outline_hscroll_right` floors at 0 but does NOT clamp against the + // outline's content width — that clamp is render-side (`render_outline`), covered in + // `render.rs`'s tests. + let mut app = two_committed_changesets_two_and_one_files(); + app.outline_hscroll_right(); + assert_eq!(app.outline_hscroll(), HSCROLL_STEP); + app.outline_hscroll_right(); + assert_eq!(app.outline_hscroll(), HSCROLL_STEP * 2); + } + #[test] fn stack_mode_outline_items_carry_current_and_restack_markers() { let fixture = FixtureBuilder::new() @@ -10682,6 +10804,121 @@ mod tests { ); } + // ── mouse h-wheel + outline hscroll follow-up ───────────────────────────────── + + #[test] + fn handle_hwheel_over_the_outline_pans_outline_hscroll_not_diff() { + let mut app = four_committed_changesets_three_files_each(); + app.outline_height = 5; + app.derive_outline_scroll(app.outline_items().len()); + app.hit_regions.outline = Some(Region { + x: 0, + y: 0, + w: 20, + h: 5, + }); + assert_eq!(app.outline_hscroll(), 0); + assert_eq!(app.hscroll, 0); + + app.handle_hwheel(5, 2, 4); + + assert!( + app.outline_focused(), + "an h-wheel event over the outline focuses it, like the vertical wheel" + ); + assert_eq!( + app.outline_hscroll(), + 4, + "the outline's own pan offset must move" + ); + assert_eq!(app.hscroll, 0, "the diff's shared pan offset must not move"); + } + + #[test] + fn handle_hwheel_over_the_diff_pane_pans_app_hscroll_not_outline() { + // "l1".."l40" — the widest rows ("l10".."l40") are 3 columns, so the clamp + // (`max_row_width - 1`) is 2. + let lines: String = (1..=40).map(|n| format!("l{n}\n")).collect(); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("big.txt", &lines) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.pane_height = 10; + app.hit_regions.single = Some(Region { + x: 0, + y: 0, + w: 40, + h: 10, + }); + assert_eq!(app.hscroll, 0); + + app.handle_hwheel(10, 3, 4); + + assert_eq!( + app.hscroll, 2, + "the diff's shared pan offset moves, clamped like `hscroll_right`" + ); + assert_eq!( + app.outline_hscroll(), + 0, + "the outline's own pan offset must not move" + ); + } + + #[test] + fn handle_hwheel_floors_at_zero() { + let mut app = four_committed_changesets_three_files_each(); + app.outline_height = 5; + app.derive_outline_scroll(app.outline_items().len()); + app.hit_regions.outline = Some(Region { + x: 0, + y: 0, + w: 20, + h: 5, + }); + + app.handle_hwheel(5, 2, -4); + + assert_eq!(app.outline_hscroll(), 0, "cannot pan left of column 0"); + } + + #[test] + fn handle_hwheel_outside_every_region_is_a_no_op() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline_height = 10; + app.pane_height = 10; + app.hit_regions = HitRegions { + outline: Some(Region { + x: 0, + y: 0, + w: 20, + h: 10, + }), + single: Some(Region { + x: 21, + y: 0, + w: 40, + h: 10, + }), + unstaged: None, + staged: None, + }; + let outline_focused_before = app.outline_focused(); + let hscroll_before = app.hscroll; + let outline_hscroll_before = app.outline_hscroll(); + + // On the divider, outside both recorded regions — same column CS10's click no-op test + // uses. + app.handle_hwheel(20, 0, 4); + + assert_eq!(app.outline_focused(), outline_focused_before); + assert_eq!(app.hscroll, hscroll_before); + assert_eq!(app.outline_hscroll(), outline_hscroll_before); + } + #[test] fn click_outside_every_hit_region_is_a_no_op() { let mut app = two_committed_changesets_two_and_one_files(); diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index badd23f..842a546 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -78,6 +78,8 @@ pub enum Command { OutlineBottom, OutlineStage, OutlineDiscard, + OutlineHscrollLeft, + OutlineHscrollRight, } /// One row of the action registry: a [`Command`] with its stable config identity (`view` + @@ -369,6 +371,20 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "d", description: "Discard the file/directory under the cursor", }, + Registered { + command: Command::OutlineHscrollLeft, + view: View::Outline, + name: "outline-hscroll-left", + default_keys: "<", + description: "Pan the outline left", + }, + Registered { + command: Command::OutlineHscrollRight, + view: View::Outline, + name: "outline-hscroll-right", + default_keys: ">", + description: "Pan the outline right", + }, ]; /// One matchable key press: a [`KeyCode`] plus whether Ctrl/Alt are required. **Shift is diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index d76f786..ba3c1af 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -11,7 +11,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span as TSpan}; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; -use unicode_width::UnicodeWidthChar; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::align::{CellKind, DisplayRow, InlineRow, Row}; use crate::app::{ @@ -403,6 +403,70 @@ fn hscroll_cut(text: &str, col: usize) -> (usize, bool) { (text.len(), false) } +/// Pan an already-built line of styled spans (diff content, or — as of the mouse/outline +/// follow-up — an outline row) `cols` display columns to the left. The shared core +/// [`content_spans`]/`render::render_outline` both build their spans at FULL width first, then +/// apply this — never the other way around — so every existing style/segment computation +/// (word-diff spans, syntax highlight, outline icon/label coloring) stays untouched by hscroll; +/// this function only ever drops or re-slices spans, never recolors one. +/// +/// Walks `spans` in order with a running column budget (`cols`, plus one extra reserved for the +/// left-edge marker below): a per-span [`hscroll_cut`] call consumes as much of that budget as +/// the span's own display width allows, carrying any remainder into the next span — exactly as +/// if `hscroll_cut` had been called once over the whole line's concatenated text, since spans +/// partition that text contiguously and in original order. A wide char straddling the cut is +/// dropped whole (never half-rendered) and compensated with a one-column space pad, same as +/// [`hscroll_cut`]'s own doc comment describes for a single string. Once the budget reaches `0`, +/// every remaining span is pushed through unchanged. +/// +/// When `cols == 0`, or the line has no content at all to cut, `spans` passes through unchanged +/// (no marker, no pad) — matching [`hscroll_cut`]'s own "nothing to show" cases. +fn pan_spans(spans: Vec>, cols: usize, theme: &Palette) -> Vec> { + if cols == 0 { + return spans; + } + if spans.iter().all(|s| s.content.is_empty()) { + return spans; + } + + // Reserve one extra column for the left-edge marker (decision #7's affordance) — mirrors the + // pre-refactor `content_spans`' own "cut at `hscroll`, then one column further for the + // marker" two-step. + let mut skip = cols + 1; + let mut out = Vec::with_capacity(spans.len() + 2); + out.push(TSpan::styled( + HSCROLL_MARKER.to_string(), + Style::default().fg(theme.dim), + )); + + for span in spans { + if skip == 0 { + out.push(span); + continue; + } + let text = span.content.as_ref(); + let (cut, straddled) = hscroll_cut(text, skip); + if straddled || cut < text.len() { + // The remaining budget was fully spent inside this span — everything from `cut` + // onward (possibly nothing) survives, unchanged in style. + skip = 0; + if straddled { + out.push(TSpan::styled( + " ".to_string(), + Style::default().fg(theme.foreground), + )); + } + if cut < text.len() { + out.push(TSpan::styled(text[cut..].to_string(), span.style)); + } + } else { + // The whole span fit inside the remaining budget — drop it and keep consuming. + skip = skip.saturating_sub(UnicodeWidthStr::width(text)); + } + } + out +} + /// Build the styled content spans (everything after the gutter) for one line of text, shared by /// [`build_pane_line`] (SBS) and [`build_inline_line`] (inline) — the two differ only in how they /// resolve `text`/`hl`/`emphasis` from a [`Row`] vs an [`InlineRow`] and in their gutter, not in @@ -412,13 +476,9 @@ fn hscroll_cut(text: &str, col: usize) -> (usize, bool) { /// plus per-`word_spans` strong background when `is_word_pair`; whole-line strong when not paired /// — an unpaired excess line) and `None` for `Context`/`Filler` (no background emphasis at all). /// -/// `hscroll` (display columns, [`App::hscroll`]) pans the returned spans: segments are composed -/// over the FULL, unsliced `text` exactly as before (every span offset below stays byte-based), -/// then trimmed to start at `hscroll`'s cut point (decision #6) — dropping a segment entirely if -/// it ends at or before the cut, else re-slicing its tail. When the cut actually removed content -/// (`hscroll > 0` and something preceded it), the first visible column renders [`HSCROLL_MARKER`] -/// instead (decision #7's left-edge affordance) — the real cut point in that case is one column -/// further right, to make room for the marker. +/// `hscroll` (display columns, [`App::hscroll`]) pans the returned spans via [`pan_spans`] — the +/// segments below are always composed over the FULL, unsliced `text` first (byte-identical to the +/// pre-hscroll behavior), and [`pan_spans`] applies the cut/pad/marker afterward. #[allow(clippy::too_many_arguments)] fn content_spans( text: &str, @@ -443,53 +503,21 @@ fn content_spans( } let segments = compose_segments(text.len(), &bg_spans, hl, theme); - - // Nothing panned off yet: the common case, byte-identical to pre-hscroll behavior. - let (cut, pad, marker) = if hscroll == 0 { - (0, false, false) - } else { - let (base_cut, _) = hscroll_cut(text, hscroll); - if base_cut > 0 { - // Something was actually cut — reserve column `hscroll` for the marker by cutting - // one column further in. - let (marker_cut, pad) = hscroll_cut(text, hscroll + 1); - (marker_cut, pad, true) - } else { - (0, false, false) - } - }; - - let mut spans = Vec::with_capacity(segments.len().max(1) + 2); - if marker { + let mut spans = Vec::with_capacity(segments.len().max(1)); + if segments.is_empty() && !text.is_empty() { spans.push(TSpan::styled( - HSCROLL_MARKER.to_string(), - Style::default().fg(theme.dim), - )); - } - if pad { - spans.push(TSpan::styled( - " ".to_string(), - Style::default().fg(theme.foreground), - )); - } - if segments.is_empty() && !text.is_empty() && cut < text.len() { - spans.push(TSpan::styled( - text[cut..].to_string(), + text.to_string(), Style::default().fg(theme.foreground), )); } for seg in segments { - if seg.end <= cut { - continue; - } - let start = seg.start.max(cut); let mut style = Style::default().fg(seg.fg); if let Some(bg) = seg.bg { style = style.bg(bg); } - spans.push(TSpan::styled(text[start..seg.end].to_string(), style)); + spans.push(TSpan::styled(text[seg.start..seg.end].to_string(), style)); } - spans + pan_spans(spans, hscroll, theme) } /// Build a single rendered line for one pane at a display row's resolved [`Row`]/[`CellKind`]. @@ -712,6 +740,17 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) let scroll = app.outline_scroll(); let icons = app.icon_mode(); + // Render-side upper clamp of the outline's own pan offset (mirroring `clamp_outline_scroll` + // just above) — from EVERY item's built line width, not just the visible rows: outlines are + // small (file trees, not file contents), so re-measuring the whole thing here is cheap. + let max_line_width = items + .iter() + .map(|item| build_outline_line(item, theme, icons).width()) + .max() + .unwrap_or(0); + app.clamp_outline_hscroll(max_line_width); + let hscroll = app.outline_hscroll(); + let buf = frame.buffer_mut(); for row in 0..area.height { let item_idx = scroll + row as usize; @@ -721,6 +760,7 @@ 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); + 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 { @@ -729,6 +769,7 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) line }; buf.set_line(area.x, y, &line, area.width); + apply_right_edge_marker(buf, area, y, &line, theme); } } @@ -1857,12 +1898,14 @@ fn render_pane_inline( mod tests { use ratatui::backend::TestBackend; use ratatui::buffer::Buffer; + use ratatui::style::Style; + use ratatui::text::Span as TSpan; use ratatui::Terminal; use git_workon_fixture::prelude::*; use unicode_width::UnicodeWidthChar; - use super::{hscroll_cut, render}; + use super::{hscroll_cut, pan_spans, render}; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; use crate::app::App; @@ -3002,6 +3045,77 @@ mod tests { assert_eq!(&"short"[cut..], ""); } + // ── mouse h-wheel + outline hscroll follow-up: `pan_spans` ───────────────────── + + fn spans_text(spans: &[TSpan<'static>]) -> String { + spans.iter().map(|s| s.content.as_ref()).collect() + } + + fn span(text: &str) -> TSpan<'static> { + TSpan::styled(text.to_string(), Style::default()) + } + + #[test] + fn pan_spans_at_zero_columns_is_a_pass_through() { + let theme = Palette::dark(); + let spans = vec![span("hello "), span("world")]; + let panned = pan_spans(spans.clone(), 0, &theme); + assert_eq!(spans_text(&panned), "hello world"); + assert_eq!(panned.len(), spans.len(), "unchanged, span for span"); + } + + #[test] + fn pan_spans_cuts_mid_span() { + // "hello world" panned 3 columns — the cut (plus the marker's reserved column) lands + // inside the FIRST span ("hello "), leaving its tail attached to the second span. + let theme = Palette::dark(); + let spans = vec![span("hello "), span("world")]; + let panned = pan_spans(spans, 3, &theme); + assert_eq!(spans_text(&panned), "…o world"); + } + + #[test] + fn pan_spans_cuts_exactly_at_a_span_boundary() { + // "abcdef" as three 2-char spans, panned 2 columns — the cut (plus the marker's reserved + // column) lands exactly on the boundary between the first and second span. + let theme = Palette::dark(); + let spans = vec![span("ab"), span("cd"), span("ef")]; + let panned = pan_spans(spans, 2, &theme); + assert_eq!(spans_text(&panned), "…def"); + } + + #[test] + fn pan_spans_wide_char_straddling_a_span_edge_drops_and_pads() { + // "a漢b" as two spans ("a", "漢b"), panned 1 column — the cut (plus the marker's reserved + // column) straddles the wide CJK glyph at the start of the second span: it's dropped + // whole and compensated with a one-column space. + let theme = Palette::dark(); + assert_eq!(UnicodeWidthChar::width('漢'), Some(2)); + let spans = vec![span("a"), span("漢b")]; + let panned = pan_spans(spans, 1, &theme); + assert_eq!(spans_text(&panned), "… b"); + } + + #[test] + fn pan_spans_beyond_total_width_yields_just_the_marker() { + let theme = Palette::dark(); + let spans = vec![span("ab"), span("cd")]; + let panned = pan_spans(spans, 100, &theme); + assert_eq!(spans_text(&panned), "…"); + } + + #[test] + fn pan_spans_on_empty_content_is_a_pass_through() { + let theme = Palette::dark(); + let spans = vec![span("")]; + let panned = pan_spans(spans, 5, &theme); + assert_eq!( + spans_text(&panned), + "", + "an empty line has nothing to cut, so no marker either" + ); + } + /// Build a single unstaged-file `App` with one long line, for the hscroll rendering tests — /// long enough that panning by [`crate::app::HSCROLL_STEP`]-sized steps has real room to move /// (the tests don't reference that constant directly since it's private to `app.rs`; `200` @@ -3416,6 +3530,68 @@ mod tests { ); } + // ── mouse h-wheel + outline hscroll follow-up: outline panning ───────────────── + + /// A single-changeset `App` with one file whose path is far wider than the outline's fixed + /// 35-column width, focused into the outline — for the outline hscroll rendering tests. + fn app_with_a_long_outline_path() -> App { + let long_path = format!("{}.txt", "a".repeat(80)); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file(&long_path, "content\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.focus_outline(); // opens (a lone changeset defaults closed) and focuses. + app + } + + #[test] + fn outline_panning_shows_the_left_marker_shifted_text_and_the_right_edge_marker() { + let mut app = app_with_a_long_outline_path(); + app.outline_hscroll_right(); + assert!( + app.outline_hscroll() > 0, + "the long path must give outline hscroll room to pan" + ); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + // The header row (a short label) pans fully off and shows a lone marker, so select the + // PATH row: the one where the marker is followed by the shifted 'a…a.txt' body. + let row = content + .iter() + .position(|r| r.contains('…') && r.contains('a')) + .expect("the panned path row must show the left-edge marker plus shifted content"); + // Column 34 is the outline's last column before the divider at 35 (see + // `OUTLINE_TEST_WIDTH`'s doc comment). + assert_eq!( + cell_text(&buf, 34, row as u16), + "…", + "a row wider than the outline pane must show the right-edge marker too" + ); + } + + #[test] + fn outline_render_side_clamp_caps_a_huge_pan_offset() { + let mut app = app_with_a_long_outline_path(); + for _ in 0..1000 { + app.outline_hscroll_right(); + } + assert!( + app.outline_hscroll() > 1000, + "sanity: `outline_hscroll_right` itself has no upper clamp" + ); + + render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + assert!( + app.outline_hscroll() < 1000, + "render_outline must clamp the huge offset down to the content width, got {}", + app.outline_hscroll() + ); + } + // ── CS5: file status letter + opt-in nerd-font icons ─────────────────────────── #[test] diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index f710b1c..8ebe260 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -118,10 +118,11 @@ impl PartialEq for AppEvent { type InboxMessage = io::Result; /// Map one crossterm terminal [`Event`] to the [`AppEvent`] the loop reacts to — key-press, -/// resize, and (CS10) a left-click or wheel-scroll map; key release/repeat, every other mouse -/// kind (drag, move, non-left buttons, button-up), paste, and focus events are skipped (`None`). -/// Pure and independent of any thread or channel, so it's unit-tested directly; the input -/// thread's loop body is a thin wrapper around it. +/// resize, and (CS10, extended by the mouse h-wheel follow-up) a left-click or vertical/ +/// horizontal wheel-scroll map; key release/repeat, every other mouse kind (drag, move, non-left +/// buttons, button-up), paste, and focus events are skipped (`None`). Pure and independent of any +/// thread or channel, so it's unit-tested directly; the input thread's loop body is a thin wrapper +/// around it. fn map_terminal_event(event: Event) -> Option { match event { Event::Key(key) if key.kind == KeyEventKind::Press => Some(AppEvent::Key(key)), @@ -132,6 +133,8 @@ fn map_terminal_event(event: Event) -> Option { MouseEventKind::Down(MouseButton::Left) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight ) => { Some(AppEvent::Mouse(m)) @@ -456,6 +459,8 @@ enum Action { OutlineBottom, OutlineStage, OutlineDiscard, + OutlineHscrollLeft, + OutlineHscrollRight, None, } @@ -504,6 +509,8 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::OutlineBottom => Action::OutlineBottom, Command::OutlineStage => Action::OutlineStage, Command::OutlineDiscard => Action::OutlineDiscard, + Command::OutlineHscrollLeft => Action::OutlineHscrollLeft, + Command::OutlineHscrollRight => Action::OutlineHscrollRight, } } @@ -639,6 +646,8 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::OutlineBottom => app.outline_bottom(), Action::OutlineStage => app.outline_stage(), Action::OutlineDiscard => app.outline_discard(), + Action::OutlineHscrollLeft => app.outline_hscroll_left(), + Action::OutlineHscrollRight => app.outline_hscroll_right(), Action::None => {} } false @@ -751,6 +760,10 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap MouseEventKind::Down(MouseButton::Left) => app.handle_click(m.column, m.row), MouseEventKind::ScrollDown => app.handle_wheel(m.column, m.row, 3), MouseEventKind::ScrollUp => app.handle_wheel(m.column, m.row, -3), + // 4 columns per tick — finer than `HSCROLL_STEP` since trackpads emit streams of + // ticks (see `App::handle_hwheel`'s doc comment). + MouseEventKind::ScrollRight => app.handle_hwheel(m.column, m.row, 4), + MouseEventKind::ScrollLeft => app.handle_hwheel(m.column, m.row, -4), _ => {} } false @@ -1297,6 +1310,24 @@ mod tests { ); } + /// Mouse h-wheel follow-up: `ScrollLeft`/`ScrollRight` (trackpad h-scroll, or a shift-wheel + /// the terminal reports this way) map through exactly like the vertical `ScrollUp`/ + /// `ScrollDown` pair above. + #[test] + fn map_terminal_event_maps_scroll_left_and_right() { + let scroll_left = mouse(MouseEventKind::ScrollLeft); + assert!(matches!( + map_terminal_event(Event::Mouse(scroll_left)), + Some(AppEvent::Mouse(m)) if m == scroll_left + )); + + let scroll_right = mouse(MouseEventKind::ScrollRight); + assert!(matches!( + map_terminal_event(Event::Mouse(scroll_right)), + Some(AppEvent::Mouse(m)) if m == scroll_right + )); + } + /// `AppEvent` dropped `PartialEq`/`Eq` in ADR-031 (`FileReady`'s `LoadedViews` payload wraps /// `FileView`, which has neither) — this test-only helper is the `matches!`-based replacement /// for the `assert_eq!(event, AppEvent::Key(key(...)))` shape used throughout this module's @@ -3295,4 +3326,48 @@ mod tests { apply_action(&mut app, Action::FocusOutline); assert!(app.outline_focused()); } + + /// Mouse h-wheel follow-up: a `ScrollRight` event reaches `App::handle_hwheel` (not the + /// vertical `App::handle_wheel`) when dispatched through the full `update` path — mirroring + /// how the existing vertical-wheel tests exercise `App::handle_wheel` directly, but this one + /// goes through `map_terminal_event` + `update`'s mouse arm to also pin the event mapping. + #[test] + fn scroll_right_event_reaches_handle_hwheel_via_update() { + use git_workon_fixture::prelude::*; + use workon_review::app::Region; + + let lines: String = (1..=40).map(|n| format!("l{n}\n")).collect(); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("big.txt", &lines) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.pane_height = 10; + app.hit_regions.single = Some(Region { + x: 0, + y: 0, + w: 40, + h: 10, + }); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + assert_eq!(app.hscroll, 0); + + let raw = MouseEvent { + kind: MouseEventKind::ScrollRight, + column: 10, + row: 3, + modifiers: KeyModifiers::NONE, + }; + // Round-trip through the real mapping first, matching how the input thread feeds `update`. + let mapped = map_terminal_event(Event::Mouse(raw)).expect("ScrollRight must map"); + update(&mut app, &km, &mut pending, mapped); + + assert!( + app.hscroll > 0, + "a ScrollRight event over the diff pane must pan App::hscroll via handle_hwheel" + ); + } } From 7bdd1ac04a1cf31af85c9ac37592980c11f5d611 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 13 Jul 2026 13:53:41 -0400 Subject: [PATCH 132/203] feat(review): distinguish outline changeset headers with counter and accent --- git-workon-review/src/app.rs | 5 + git-workon-review/src/outline.rs | 16 +++ git-workon-review/src/render.rs | 196 ++++++++++++++++++++++++++++--- git-workon-review/src/theme.rs | 32 +++++ 4 files changed, 232 insertions(+), 17 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 1afc0f4..9eda4bb 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -8673,6 +8673,7 @@ mod tests { items[0], OutlineItem::Header { cs_idx: 0, + n: 2, label: "cs-a".to_string(), current: false, needs_restack: false, @@ -8688,6 +8689,7 @@ mod tests { header_b, &OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-b".to_string(), current: true, needs_restack: true, @@ -8783,6 +8785,7 @@ mod tests { vec![ OutlineItem::Header { cs_idx: 0, + n: 2, label: "cs-pending".to_string(), current: true, needs_restack: false, @@ -8791,6 +8794,7 @@ mod tests { }, OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-failed".to_string(), current: false, needs_restack: false, @@ -8963,6 +8967,7 @@ mod tests { items_after[app.outline_cursor()], OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-b".to_string(), current: true, needs_restack: false, diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 076e85a..17991e7 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -193,6 +193,10 @@ pub enum OutlineItem { /// A changeset header — emitted in [`OutlineMode::Stack`]/[`OutlineMode::StackTree`]. Header { cs_idx: usize, + /// Changeset count (CS1, `outline-header-polish`) — paired with `cs_idx` at render time + /// to draw the `[i/n]` counter (`i` = `cs_idx + 1`, base=1). Always `changesets.len()` at + /// build time, so it's the same for every `Header` row a given `build_items` call emits. + n: usize, label: String, current: bool, needs_restack: bool, @@ -274,10 +278,12 @@ pub fn build_items( /// `file_idx` are computed from the ORIGINAL (base -> head) enumeration before any reversal, so /// they stay true indices into `App::changesets` either way. fn build_stack(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec { + let n = changesets.len(); let mut items = Vec::new(); for (cs_idx, cs) in scan_order(changesets, order) { items.push(OutlineItem::Header { cs_idx, + n, label: cs.label.clone(), current: cs.current, needs_restack: cs.needs_restack, @@ -487,10 +493,12 @@ fn build_tree(changesets: &[OutlineChangeset]) -> Vec { /// changeset's own copy gets its own row" rule). `order` picks which end of the stack paints /// first, same as [`build_stack`]; `cs_idx`/`file_idx` stay true indices regardless. fn build_stack_tree(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec { + let n = changesets.len(); let mut items = Vec::new(); for (cs_idx, cs) in scan_order(changesets, order) { items.push(OutlineItem::Header { cs_idx, + n, label: cs.label.clone(), current: cs.current, needs_restack: cs.needs_restack, @@ -591,6 +599,7 @@ mod tests { vec![ OutlineItem::Header { cs_idx: 0, + n: 2, label: "cs-a".to_string(), current: false, needs_restack: false, @@ -607,6 +616,7 @@ mod tests { }, OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-b".to_string(), current: true, needs_restack: true, @@ -639,6 +649,7 @@ mod tests { vec![ OutlineItem::Header { cs_idx: 0, + n: 2, label: "cs-pending".to_string(), current: false, needs_restack: false, @@ -647,6 +658,7 @@ mod tests { }, OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-failed".to_string(), current: false, needs_restack: false, @@ -914,6 +926,7 @@ mod tests { vec![ OutlineItem::Header { cs_idx: 0, + n: 2, label: "cs-a".to_string(), current: false, needs_restack: false, @@ -936,6 +949,7 @@ mod tests { }, OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-b".to_string(), current: true, needs_restack: true, @@ -971,6 +985,7 @@ mod tests { items[0], OutlineItem::Header { cs_idx: 2, + n: 3, label: "cs-c".to_string(), current: true, needs_restack: false, @@ -1025,6 +1040,7 @@ mod tests { items[0], OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-b".to_string(), current: true, needs_restack: false, diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index ba3c1af..8f65bb6 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -106,17 +106,22 @@ fn diffstat_prefixes(icons: IconMode) -> (String, String) { } } -/// The shared changeset-title span run — `[current-marker] [branch-icon] label [warn-marker]` — -/// drawn identically by `build_outline_line`'s Header arm and [`changeset_summary_lines`] (whose -/// doc comment promises exactly that sameness); extracting it makes the promise structural -/// instead of hand-synced. Failed/loading markers are NOT included: the two call sites place -/// them differently (trailing spans on the header row vs. a line of their own in the summary). +/// The shared changeset-title span run — `[current-marker] [branch-icon] ([i/n] )label +/// [warn-marker]` — drawn by both `build_outline_line`'s Header arm and +/// [`changeset_summary_lines`]. **The two call sites no longer render identically** (CS1, +/// `outline-header-polish`): `counter` is `Some((cs_idx + 1, n))` for the outline's Header row +/// only, and its presence ALSO switches the label from the plain [`Palette::foreground`] look to +/// [`Palette::heading_fg`] + bold — the summary panel passes `None` and keeps the original +/// foreground-bold label with no counter, matching its pre-CS1 appearance exactly. Failed/loading +/// markers are still NOT included: the two call sites place them differently (trailing spans on +/// the header row vs. a line of their own in the summary). fn changeset_title_spans( label: &str, current: bool, needs_restack: bool, theme: &Palette, icons: IconMode, + counter: Option<(usize, usize)>, ) -> Vec> { let marker = if current { format!("{} ", current_marker(icons)) @@ -130,11 +135,22 @@ fn changeset_title_spans( Style::default().fg(theme.dim), )); } + // The `[i/n]` counter and the accented label are outline-only (`counter.is_some()`) — see + // this fn's doc comment for why the summary panel's `None` call site is unaffected. + let label_fg = if counter.is_some() { + theme.heading_fg + } else { + theme.foreground + }; + if let Some((i, n)) = counter { + spans.push(TSpan::styled( + format!("[{i}/{n}] "), + Style::default().fg(theme.dim), + )); + } spans.push(TSpan::styled( label.to_string(), - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), + Style::default().fg(label_fg).add_modifier(Modifier::BOLD), )); if needs_restack { spans.push(TSpan::styled( @@ -715,8 +731,10 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect } /// 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`) and needs-restack glyph -/// (amber ⚠, [`crate::theme::Palette::warn_fg`] — locked decision #9's outline half); [`OutlineItem::File`]s carry an +/// 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 one-character staged-ness glyph (blank for a committed changeset's files — see /// [`crate::outline::StagedStatus`]'s doc comment for why no special-casing is needed here), and /// the path. The cursor row (the outline's OWN cursor — a separate coordinate space from the @@ -817,14 +835,22 @@ fn change_letter_color(change: FileStatus, theme: &Palette) -> Color { fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> Line<'static> { match item { OutlineItem::Header { + cs_idx, + n, label, current, needs_restack, loading, failed, - .. } => { - let mut spans = changeset_title_spans(label, *current, *needs_restack, theme, icons); + let mut spans = changeset_title_spans( + label, + *current, + *needs_restack, + theme, + icons, + Some((cs_idx + 1, *n)), + ); // ADR-031: a Failed changeset's marker wins over Pending's (a slot is never both, // but Failed is the more actionable state to surface if it somehow were). if *failed { @@ -1252,8 +1278,10 @@ fn push_summary_body( } /// Build a [`ChangesetSummary`]'s lines: title line (the same current/needs-restack markers -/// `build_outline_line`'s Header arm draws — structurally shared via [`changeset_title_spans`]), -/// a loading/failed line OR the per-file list + totals line. +/// `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. fn changeset_summary_lines( summary: &ChangesetSummary, height: usize, @@ -1268,6 +1296,7 @@ fn changeset_summary_lines( summary.needs_restack, theme, icons, + None, ))); if summary.failed { @@ -3330,6 +3359,135 @@ mod tests { ); } + #[test] + fn outline_header_shows_true_position_counter_regardless_of_display_order() { + // CS1 (`outline-header-polish`): the `[i/n]` counter is the TRUE stack position + // (`cs_idx + 1`), never a display-order index — HeadFirst (the default) paints cs-b + // (true index 1) before cs-a (true index 0), so the counter must read `[2/2]` on cs-b's + // row and `[1/2]` on cs-a's, in that display order, not `[1/2]` then `[2/2]`. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + + // Skip y=0: the full-width winbar also renders a `[i/n] ` fragment for the + // CURRENT changeset (cs-b) — an unskipped search for "[2/2]" would false-positive on it. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row_b = content + .iter() + .position(|r| r.contains("[2/2]")) + .expect("cs-b (true index 1) must show counter [2/2]"); + let row_a = content + .iter() + .position(|r| r.contains("[1/2]")) + .expect("cs-a (true index 0) must show counter [1/2]"); + assert!( + row_b < row_a, + "HeadFirst shows cs-b's header before cs-a's, but the counter stays the true stack \ + position, not a display-order index — got:\n{}", + content.join("\n") + ); + } + + #[test] + fn outline_header_label_carries_the_heading_accent_color() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + + // Skip y=0: the full-width winbar ALSO names cs-b (it's `current`) via its own + // `[i/n] ` fragment — in plain foreground, not the outline's heading + // accent — so an unskipped search for "cs-b" would false-positive onto the winbar's own + // label instead of the outline header row this test means to inspect. `content`'s index + // `i` is buffer row `i + 1` (the skip), so every `buf` query below adds 1 back. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row = content + .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; + assert_eq!( + buf.cell((label_x, row as u16 + 1)).unwrap().style().fg, + Some(Palette::dark().heading_fg), + "expected the outline header's label to carry Palette::dark().heading_fg" + ); + } + + #[test] + fn summary_panel_title_has_no_counter_and_keeps_the_plain_foreground_look() { + // CS1's Gotcha: the counter + accent are outline-only — the summary panel's title (shared + // via `changeset_title_spans`, `counter: None`) must render exactly as it did pre-CS1. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.toggle_outline(); + app.toggle_outline(); + 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; + let delta = header_idx - app.outline_cursor() as i64; + app.outline_move_by(delta); + 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) + .map(|y| { + (36..buf.area.width) + .map(|x| cell_text(&buf, x, y)) + .collect::() + }) + .collect(); + let joined = body_rows.join("\n"); + assert!( + !joined.contains("[2/2]") && !joined.contains("[1/2]"), + "the outline-only counter must not leak into the summary panel's title, got:\n{joined}" + ); + let row = body_rows + .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, + Some(Palette::dark().foreground), + "the summary panel's title must keep its plain foreground look, not the outline's \ + heading accent" + ); + } + #[test] fn render_preserves_a_wheel_scrolled_outline_viewport() { // The peek model's load-bearing render change: `render_outline` bounds-CLAMPS the @@ -3558,11 +3716,15 @@ mod tests { let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); - // The header row (a short label) pans fully off and shows a lone marker, so select the - // PATH row: the one where the marker is followed by the shifted 'a…a.txt' body. + // The header row's own label is short, but CS1's `[i/n] ` counter widens it enough that a + // single hscroll step no longer pans it fully off — it can now ALSO show a lone marker + // plus a stray `a` (from a branch name like `main`), so a bare "contains 'a'" check no + // longer picks out the PATH row unambiguously. Look for a run of the synthetic path's + // repeated `a`s instead (`app_with_a_long_outline_path`'s path is 80 `a`s + `.txt`) — no + // header label plausibly contains four `a`s in a row. let row = content .iter() - .position(|r| r.contains('…') && r.contains('a')) + .position(|r| r.contains('…') && r.contains("aaaa")) .expect("the panned path row must show the left-edge marker plus shifted content"); // Column 34 is the outline's last column before the divider at 35 (see // `OUTLINE_TEST_WIDTH`'s doc comment). diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 2917eba..b126cc2 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -26,6 +26,11 @@ //! 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). +//! +//! **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 — +//! it's the outline's changeset-header-row accent, used only there (see +//! `render::changeset_title_spans`'s doc comment for the outline-only gating). use ratatui::style::Color; @@ -224,6 +229,12 @@ pub struct Palette { /// "current" reads unambiguously at a glance. Promoted from `render.rs`'s `FG_CURRENT` const /// (CS2). pub current_fg: Color, + /// Accent tone for a changeset header row's label (CS1, `outline-header-polish`) — a cyan + /// (base0C), distinct from [`Palette::current_fg`]'s green so "this is a section heading" + /// reads independently of "this is the current changeset." Used ONLY by the outline's Header + /// rows (`render::changeset_title_spans`'s `counter` param gates it) — the summary panel's + /// changeset title keeps the plain [`Palette::foreground`] look. + pub heading_fg: Color, /// Whether [`crate::render::render`] should paint the whole frame with [`Palette::background`] /// before drawing panes. `true` for the curated [`Palette::dark`]/[`Palette::light`] schemes /// (and the probe's curated fallback); `false` for [`Palette::from_terminal`], so `auto` @@ -264,6 +275,9 @@ impl Palette { error_fg: Color::Rgb(220, 60, 60), warn_fg: Color::Rgb(214, 158, 46), current_fg: Color::Rgb(96, 200, 128), + // CS1: brand new (no historical `render.rs` const to reproduce), so this takes the + // scheme's base0C directly rather than an authored literal. + heading_fg: base.slot(12), paint_canvas: true, } } @@ -316,6 +330,7 @@ impl Palette { error_fg: red, warn_fg: base.slot(10), // base0A current_fg: green, + heading_fg: cyan, paint_canvas: true, } } @@ -359,6 +374,7 @@ impl Palette { error_fg: base.slot(8), warn_fg: base.slot(10), current_fg: base.slot(11), + heading_fg: base.slot(12), // Unlike the curated schemes, `auto` must NOT paint over the terminal's own // background — base00 here IS the probed terminal bg, so painting a solid canvas // would defeat terminal transparency/background images for no benefit (the probed @@ -441,6 +457,15 @@ mod tests { assert_eq!(t.current_fg, Color::Rgb(96, 200, 128)); } + #[test] + fn dark_heading_fg_takes_the_eighties_dark_cyan_accent() { + // CS1: no historical constant to reproduce (this field is new) — unlike + // `dark_semantic_fg_matches_the_historical_render_rs_constants` above, it takes base0C + // straight from the scheme. + let t = Palette::dark(); + assert_eq!(t.heading_fg, Color::Rgb(0x66, 0xcc, 0xcc)); // base0C + } + #[test] fn dark_chrome_fields_match_the_eighties_dark_ramp_and_paint_the_canvas() { // `dark()`'s canvas/chrome must come from the SAME ramp `Palette::dark`'s syntax/tints @@ -554,6 +579,12 @@ mod tests { assert_eq!(t.current_fg, Color::Rgb(0x50, 0xa1, 0x4f)); // base0B } + #[test] + fn light_heading_fg_takes_one_lights_cyan_accent() { + let t = Palette::light(); + assert_eq!(t.heading_fg, Color::Rgb(0x01, 0x84, 0xbc)); // base0C + } + /// A synthetic probed scheme with a distinct value in every slot and the given `base00`, so a /// test can assert `from_terminal`'s syntax slots came from the probed scheme (not a curated /// one) and read the base00 luminance branch. @@ -636,6 +667,7 @@ mod tests { assert_eq!(palette.error_fg, probed.slot(8)); assert_eq!(palette.warn_fg, probed.slot(10)); assert_eq!(palette.current_fg, probed.slot(11)); + assert_eq!(palette.heading_fg, probed.slot(12)); assert_ne!(palette.error_fg, Palette::dark().error_fg); } From 8bca44695c6bf341c6a312cf2efdeb08dad96291 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 13 Jul 2026 14:29:25 -0400 Subject: [PATCH 133/203] feat(review): smart path render and tighter tree indent --- git-workon-review/src/render.rs | 237 +++++++++++++++++++++++++++++--- 1 file changed, 217 insertions(+), 20 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 8f65bb6..4d2e395 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -737,7 +737,9 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// [`crate::theme::Palette::warn_fg`] — locked decision #9's outline half); [`OutlineItem::File`]s carry an /// indent, a one-character staged-ness glyph (blank for a committed changeset's files — see /// [`crate::outline::StagedStatus`]'s doc comment for why no special-casing is needed here), and -/// the path. The cursor row (the outline's OWN cursor — a separate coordinate space from the +/// the path — Flat/Stack rows (CS2) split it into `basename dim/dirname` (no suffix for a +/// root-level file); Tree/StackTree rows already carry the directory via ancestor Dir rows, so +/// `path` there is just the bare basename. 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 /// legible even after focus returns to the diff). `&mut App` (CS2, precedent: [`render_body`] @@ -796,19 +798,21 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) /// its parent's last child) or blank space (if it was), and the last element draws the row's own /// `╰─`/`├─` connector — CS4 rounds the last-child corner (`╰`, U+2570) from the square `└` /// (U+2514); there's no widely-supported rounded "tee" glyph, so the non-last `├─` connector is -/// unchanged. +/// unchanged. CS2 tightens indent to 2 cols/level: continuation is `│ ` (bar + space, no third +/// column), and connectors (`├─`/`╰─`) carry no trailing space — the glyph that follows hugs the +/// connector directly. fn tree_prefix(guides: &[bool]) -> String { let mut s = String::new(); let Some((&is_last, ancestors)) = guides.split_last() else { return s; }; for &last in ancestors { - s.push_str(if last { " " } else { "\u{2502} " }); + s.push_str(if last { " " } else { "\u{2502} " }); } s.push_str(if is_last { - "\u{2570}\u{2500} " + "\u{2570}\u{2500}" } else { - "\u{251C}\u{2500} " + "\u{251C}\u{2500}" }); s } @@ -932,10 +936,34 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> L Style::default().fg(color.unwrap_or(theme.foreground)), )); } - spans.push(TSpan::styled( - path.clone(), - Style::default().fg(theme.foreground), - )); + // Flat/Stack rows (empty `guides`) split `path` at render time into `basename dim/ + // dirname` — basename first (bright, matching the tree modes' bare-name leaves) so + // truncation eats the dim dirname before the name a user is scanning for (CS2 + // gotcha). Tree/StackTree rows (non-empty `guides`) already carry the path via + // ancestor Dir rows, so `path` there is already just the basename — render it as-is. + if guides.is_empty() { + match path.rsplit_once('/') { + Some((dir, base)) => { + spans.push(TSpan::styled( + base.to_string(), + Style::default().fg(theme.foreground), + )); + spans.push(TSpan::styled( + format!(" {dir}"), + Style::default().fg(theme.dim), + )); + } + None => spans.push(TSpan::styled( + path.clone(), + Style::default().fg(theme.foreground), + )), + } + } else { + spans.push(TSpan::styled( + path.clone(), + Style::default().fg(theme.foreground), + )); + } Line::from(spans) } } @@ -3644,20 +3672,46 @@ mod tests { // buffer row starting at y=1 (y=0 is the winbar): `src/` (dir, root, NOT the root's last // child — `top.txt` follows), `a.txt` nested one level under `src/` (the only — hence // last — child of `src/`), then `top.txt` (file, root, IS the root's last child). - assert!( - content[1].contains('\u{251C}') && content[1].contains("src/"), - "expected row 1 to be the src/ directory row with a non-last '├─' guide, got:\n{}", + // + // CS2 tightens `tree_prefix` to 2 cols/level with no trailing space on the connector, so + // these are exact-column checks (not just `contains`) — every rendered cell here is one + // column wide, so `chars()` (not byte) indexing IS the display column (the guide glyphs + // themselves are multi-byte, which is exactly why byte indexing would be wrong). + let row1: Vec = content[1].chars().collect(); + assert_eq!( + row1[0..6], + ['\u{251C}', '\u{2500}', 's', 'r', 'c', '/'], + "expected row 1 to be a tight '├─src/' (2-col connector, no trailing space), got:\n{}", content.join("\n") ); - assert!( - content[2].contains('\u{2570}') && content[2].contains("a.txt"), - "expected row 2 to be src/a.txt, indented under src/ with its own last-child \ - rounded '╰─' guide, got:\n{}", + let row2: Vec = content[2].chars().collect(); + assert_eq!( + row2[0..4], + ['\u{2502}', ' ', '\u{2570}', '\u{2500}'], + "expected row 2's guide to be a tight '│ ╰─' (continuation + last-child connector, \ + both 2 cols), got:\n{}", content.join("\n") ); - assert!( - content[3].contains('\u{2570}') && content[3].contains("top.txt"), - "expected row 3 to be top.txt with a last-child rounded '╰─' guide, got:\n{}", + assert_eq!( + row2[7..12], + ['a', '.', 't', 'x', 't'], + "expected src/a.txt's basename to start immediately after the 4-col guide + 1-col \ + glyph + 1-col letter + 1-col space, got:\n{}", + content.join("\n") + ); + let row3: Vec = content[3].chars().collect(); + assert_eq!( + row3[0..2], + ['\u{2570}', '\u{2500}'], + "expected row 3's guide to be a tight '╰─' (root-level last-child, 2 cols, no \ + trailing space), got:\n{}", + content.join("\n") + ); + assert_eq!( + row3[5..12], + ['t', 'o', 'p', '.', 't', 'x', 't'], + "expected top.txt to start immediately after the 2-col guide + 1-col glyph + 1-col \ + letter + 1-col space, got:\n{}", content.join("\n") ); } @@ -3680,7 +3734,14 @@ mod tests { let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); // Row 3 is top.txt (see the test above) — a File row with a non-empty guide vector. let row = outline_row(&buf, 3); - let guide_x = row.find('\u{2570}').expect("rounded guide present") as u16; + // `String::find` returns a BYTE offset, not a display column — the rounded guide glyph is + // multi-byte, so a `chars()` (not byte) position is what actually lines up with the + // column-indexed `buf.cell` lookup below (every rendered cell here is one column wide). + let row_chars: Vec = row.chars().collect(); + let guide_x = row_chars + .iter() + .position(|&c| c == '\u{2570}') + .expect("rounded guide present") as u16; assert_eq!( buf.cell((guide_x, 3)).unwrap().style().fg, Some(Palette::dark().dim), @@ -3688,6 +3749,142 @@ mod tests { ); } + // ── CS2 (outline-row-shape): smart path render ───────────────────────────────── + + #[test] + fn outline_stack_mode_file_row_splits_basename_and_dim_dirname() { + // Stack mode keeps `guides` empty, so a nested path (`src/a.txt`) must split at render + // time into basename-first, then the dirname in `theme.dim` — ancestors don't carry the + // path here (unlike Tree mode), so the row has to spell it out itself. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = changeset_with_nested_paths(&fixture); + if !app.outline_open() { + app.toggle_outline(); + } + assert_eq!( + app.outline_mode(), + crate::outline::OutlineMode::Stack, + "sanity: default mode is Stack, so guides stay empty and this exercises CS2's split" + ); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0: the full-width winbar also names the current file (possibly `src/a.txt` + // itself), so an unskipped search could false-positive onto it instead of the outline's + // own row below it. `content`'s index `i` is buffer row `i + 1` (the skip), so every + // `buf` query below adds 1 back. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row_idx = content + .iter() + .position(|r| r.contains("a.txt") && r.contains("src")) + .expect("src/a.txt's split row present"); + let row_chars: Vec = content[row_idx].chars().collect(); + let buf_y = row_idx as u16 + 1; + + let basename_x = row_chars + .windows(5) + .position(|w| w == ['a', '.', 't', 'x', 't']) + .expect("basename 'a.txt' present in its own row") as u16; + assert_eq!( + buf.cell((basename_x, buf_y)).unwrap().style().fg, + Some(Palette::dark().foreground), + "expected the basename to carry the plain (bright) foreground, got:\n{}", + content[row_idx] + ); + + // Two blank columns separate the basename from the dirname (CS2: "basename dim/ + // dirname"), so the dirname starts right after them. + let dirname_x = basename_x + 5 + 2; + assert_eq!( + row_chars[dirname_x as usize], 's', + "expected the dirname 'src' to start two columns after the basename, got:\n{}", + content[row_idx] + ); + assert_eq!( + buf.cell((dirname_x, buf_y)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected the dirname to carry theme.dim, got:\n{}", + content[row_idx] + ); + assert!( + basename_x < dirname_x, + "basename must render BEFORE the dim dirname (basename-first ordering is what makes \ + truncation eat the dirname first), got:\n{}", + content[row_idx] + ); + } + + #[test] + fn outline_root_level_file_gets_no_dirname_suffix() { + // A root-level file (no `/` in its path) gets no suffix at all — no "(root)" + // placeholder, just the bare basename. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = changeset_with_nested_paths(&fixture); // top.txt is root-level + if !app.outline_open() { + app.toggle_outline(); + } + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0: see the split test above — the winbar also names the current file. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row = content + .iter() + .find(|r| r.contains("top.txt")) + .expect("top.txt's row present"); + assert!( + row.trim_end().ends_with("top.txt"), + "a root-level file must render with no trailing suffix after its basename, got: \ + {row:?}" + ); + } + + #[test] + fn outline_flat_row_truncation_eats_the_dim_dirname_first() { + // A pane-width-exceeding Flat-mode row must truncate the (later, dim) dirname before it + // ever touches the (earlier, bright) basename — that ordering is the whole point of + // basename-first rendering (CS2 gotcha). + let long_dir = "reallyquiteverbosedirectoryname"; + let path = format!("{long_dir}/x.txt"); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file(&path, "content\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + if !app.outline_open() { + app.toggle_outline(); + } + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0: see the split test above — the winbar also names the current file (the long + // path itself here), so an unskipped search would false-positive onto it. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row_idx = content + .iter() + .position(|r| r.contains("x.txt")) + .expect("x.txt's row present"); + assert!( + !content[row_idx].contains(long_dir), + "the full dirname must NOT fit/appear — truncation should have eaten part of it, \ + got: {:?}", + content[row_idx] + ); + // Column 34 is the outline's last column before the divider at 35 (see + // `OUTLINE_TEST_WIDTH`'s doc comment). `content`'s index is buffer row `+ 1` (the y=0 + // skip above). + assert_eq!( + cell_text(&buf, 34, row_idx as u16 + 1), + "\u{2026}", + "the truncated row must show the right-edge marker at the pane's last column" + ); + } + // ── mouse h-wheel + outline hscroll follow-up: outline panning ───────────────── /// A single-changeset `App` with one file whose path is far wider than the outline's fixed From 037d9aad0d8ec666d7f8768e4c966675fe18a057 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 13 Jul 2026 15:03:47 -0400 Subject: [PATCH 134/203] feat(review): git-style XY status matrix for outline files --- git-workon-review/src/outline.rs | 36 +-- git-workon-review/src/render.rs | 457 +++++++++++++++++++++++++++---- git-workon-review/src/theme.rs | 42 +++ 3 files changed, 446 insertions(+), 89 deletions(-) diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 17991e7..79c9361 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -80,12 +80,15 @@ fn scan_order( entries } -/// A file's staged-ness for the outline's status column — a minimal indicator (locked CS3 -/// scope: NOT the prototype's X/Y two-column git-status matrix). Only meaningful for the -/// uncommitted changeset's files; a committed changeset's files always resolve to `None` -/// because their `unstaged_idx`/`staged_idx` maps are always-empty (see +/// A file's staged-ness for the outline's status column — the data model `render.rs` derives its +/// git-porcelain-style X/Y two-column status matrix from (CS3, `outline-status-xy`). Only +/// meaningful for the uncommitted changeset's files; a committed changeset's files always +/// resolve to `None` because their `unstaged_idx`/`staged_idx` maps are always-empty (see /// `DiffState::from_committed`) — the same "derive, don't special-case" collapse /// `effective_zoom` already relies on, so no committed-specific branch is needed here either. +/// `render::build_outline_line`'s File arm reads `None` as "render a committed single letter + +/// pad column" and `Unstaged`/`Staged`/`Partial` as "render the X/Y matrix" — see that fn's doc +/// comment. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum StagedStatus { /// No staged/unstaged sub-diff info for this file (a committed changeset's file, or an @@ -112,31 +115,6 @@ impl StagedStatus { (false, false) => StagedStatus::None, } } - - /// The single-character glyph the outline renders in the status column, or a blank space - /// for [`StagedStatus::None`] (keeps every file row's path starting at the same column - /// regardless of whether it carries a status). - pub fn glyph(self) -> char { - match self { - StagedStatus::None => ' ', - StagedStatus::Unstaged => '+', - StagedStatus::Staged => '\u{2713}', // ✓ - StagedStatus::Partial => '\u{25D0}', // ◐ - } - } - - /// The nerd-font equivalent of [`StagedStatus::glyph`] (CS3, `workon.review.icons = - /// nerd`) — picked from the classic BMP `fa` set for wider font compatibility (see - /// `icons.rs`'s module doc). [`StagedStatus::None`] stays a blank space, same as - /// [`StagedStatus::glyph`], since there's no status to convey. - pub fn nerd_glyph(self) -> char { - match self { - StagedStatus::None => ' ', - StagedStatus::Unstaged => '\u{f067}', // nf-fa-plus - StagedStatus::Staged => '\u{f00c}', // nf-fa-check - StagedStatus::Partial => '\u{f042}', // nf-fa-adjust - } - } } /// One file's outline-relevant data, as extracted from its owning changeset by diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 4d2e395..878e10d 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -735,8 +735,8 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// 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 one-character staged-ness glyph (blank for a committed changeset's files — see -/// [`crate::outline::StagedStatus`]'s doc comment for why no special-casing is needed here), and +/// 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 /// root-level file); Tree/StackTree rows already carry the directory via ancestor Dir rows, so /// `path` there is just the bare basename. The cursor row (the outline's OWN cursor — a separate coordinate space from the @@ -817,17 +817,82 @@ fn tree_prefix(guides: &[bool]) -> String { s } -/// The [`FileStatus`] change-letter's foreground color (CS5): a create-like status (Added/ -/// Untracked) reuses the theme's `add_strong` tint, a destroy-like status (Deleted) reuses -/// `del_strong`, and everything else (Modified/Renamed/Copied/Unmerged — a change to EXISTING -/// content, not a create/destroy) gets the theme's neutral `foreground`. No new [`Palette`] -/// fields — this is deliberately just a remap of tints CS4's summary rows already use. -fn change_letter_color(change: FileStatus, theme: &Palette) -> Color { +/// Placeholder glyph for an empty XY status column (CS3, `outline-status-xy`) — U+00B7 middle +/// dot, always `theme.dim`, standing in for "nothing to report on this axis." Deliberately not a +/// space: the two-column matrix should read as a grid even when one side is empty, not look like +/// a ragged single-letter row. +const STATUS_PLACEHOLDER: char = '\u{b7}'; + +/// A committed changeset's single-letter status color (CS3): 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 +/// [`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 | FileStatus::Untracked => theme.add_strong, + FileStatus::Added => theme.add_strong, FileStatus::Deleted => theme.del_strong, - FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied | FileStatus::Unmerged => { - theme.foreground + FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied => theme.modified_fg, + FileStatus::Untracked | FileStatus::Unmerged => theme.dim, + } +} + +/// Build a file row's two-column status matrix (CS3, `outline-status-xy`) — always exactly 2 +/// [`TSpan`]s' worth of display columns, in every mode, so committed and uncommitted rows stay +/// aligned (the changeset's Gotcha). +/// +/// - `change == FileStatus::Untracked` wins over everything else and renders a dim `??` — noise, +/// not danger, regardless of `status` (see [`crate::outline::StagedStatus`]'s doc comment: an +/// untracked worktree file is always `Unstaged`, but git's own convention for untracked is `??`, +/// not a staged-ness-derived letter). +/// - `StagedStatus::None` is the committed-changeset case (see that type's doc comment for why no +/// special-casing is needed to detect it): a single [`FileStatus::letter`] colored by +/// [`committed_letter_color`], plus a blank pad column. +/// - `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. +fn outline_status_spans( + status: crate::outline::StagedStatus, + change: FileStatus, + theme: &Palette, +) -> Vec> { + use crate::outline::StagedStatus; + + if change == FileStatus::Untracked { + return vec![TSpan::styled( + "??".to_string(), + Style::default().fg(theme.dim), + )]; + } + match status { + StagedStatus::None => { + let letter = change.letter(); + vec![ + TSpan::styled( + letter.to_string(), + Style::default().fg(committed_letter_color(change, theme)), + ), + TSpan::styled(" ".to_string(), Style::default().fg(theme.foreground)), + ] + } + StagedStatus::Unstaged | StagedStatus::Staged | StagedStatus::Partial => { + let letter = change.letter(); + let staged = matches!(status, StagedStatus::Staged | StagedStatus::Partial); + 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 + }; + vec![ + TSpan::styled(x_char.to_string(), Style::default().fg(x_color)), + TSpan::styled(y_char.to_string(), Style::default().fg(y_color)), + ] } } } @@ -890,22 +955,18 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> L guides, .. } => { - let glyph = match icons { - IconMode::Nerd => status.nerd_glyph(), - IconMode::None => status.glyph(), - }; - let letter = change.letter(); // Empty `guides` (Flat/Stack modes) keeps the original two-space indent; a // non-empty `guides` (Tree/StackTree modes) draws tree connectors instead — see // `OutlineItem`'s doc comment for why emptiness is the mode signal. CS4: a non-empty // prefix (real tree connectors) gets its own `theme.dim`-styled span — matching the // Dir row's already-dim guides — so the guide lines read as quiet structure, not part - // of the file's own status glyph; the empty two-space indent has nothing visible to - // dim, so it stays bundled with the glyph span below. + // of the file's own status column. The status matrix itself (CS3, + // `outline_status_spans`) is always exactly 2 display columns, same width the old + // glyph+letter pair occupied, so this swap doesn't shift anything after it. let mut spans = Vec::new(); if guides.is_empty() { spans.push(TSpan::styled( - format!(" {glyph}"), + " ".to_string(), Style::default().fg(theme.foreground), )); } else { @@ -913,15 +974,8 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> L tree_prefix(guides), Style::default().fg(theme.dim), )); - spans.push(TSpan::styled( - glyph.to_string(), - Style::default().fg(theme.foreground), - )); } - spans.push(TSpan::styled( - letter.to_string(), - Style::default().fg(change_letter_color(*change, theme)), - )); + spans.extend(outline_status_spans(*status, *change, theme)); spans.push(TSpan::styled( " ".to_string(), Style::default().fg(theme.foreground), @@ -1962,7 +2016,7 @@ mod tests { use git_workon_fixture::prelude::*; use unicode_width::UnicodeWidthChar; - use super::{hscroll_cut, pan_spans, render}; + use super::{hscroll_cut, pan_spans, render, STATUS_PLACEHOLDER}; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; use crate::app::App; @@ -3951,45 +4005,325 @@ mod tests { ); } - // ── CS5: file status letter + opt-in nerd-font icons ─────────────────────────── + // ── CS3 (`outline-status-xy`): git-style X/Y status matrix ───────────────────── + + /// Render `fixture` (a lone uncommitted changeset with one file at `path`) and return the + /// buffer row + its char cells for the file row matching `path`. Skips y=0 (the winbar also + /// names the current file, which can false-positive a `contains(path)` search). + fn render_outline_file_row(fixture: &Fixture, path: &str) -> (Buffer, u16, Vec) { + let mut app = app_from_fixture(fixture); + // A lone changeset defaults the outline closed — force it open so this render test can + // inspect its rows (same pattern as `outline_tree_mode_renders_directory_rows_with_tree_guides`). + if !app.outline_open() { + app.toggle_outline(); + } + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let (row_idx, row) = content + .iter() + .enumerate() + .find(|(_, r)| r.contains(path)) + .map(|(i, r)| (i, r.clone())) + .unwrap_or_else(|| panic!("{path}'s file row present")); + let y = row_idx as u16 + 1; // +1 to undo the y=0 skip above. + (buf, y, row.chars().collect()) + } #[test] - fn outline_file_row_shows_the_modified_change_letter_in_its_own_color() { + fn outline_unstaged_file_renders_the_y_column_letter_in_del_strong() { + // 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). let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .unstaged_file("a.rs", "one\n", "one\nCHANGED\n") .build() .unwrap(); - let mut app = app_from_fixture(&fixture); - // A lone changeset defaults the outline closed — force it open so this render test can - // inspect its rows (same pattern as `outline_tree_mode_renders_directory_rows_with_tree_guides`). + let (buf, y, row) = render_outline_file_row(&fixture, "a.rs"); + + let x = row + .iter() + .position(|&c| c == STATUS_PLACEHOLDER) + .expect("expected the X (staged) column placeholder '·'") as u16; + assert_eq!( + row[x as usize + 1], + 'M', + "expected the Y (worktree) column to carry the Modified letter right after the \ + X placeholder, got: {:?}", + row + ); + assert_eq!( + buf.cell((x, y)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected the empty X placeholder to carry theme.dim" + ); + 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" + ); + } + + #[test] + fn outline_fully_staged_file_renders_the_x_column_letter_in_add_strong() { + // `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. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("a.rs", "new content\n") + .build() + .unwrap(); + let (buf, y, row) = render_outline_file_row(&fixture, "a.rs"); + + let x = row + .iter() + .position(|&c| c == 'A') + .expect("expected the Added letter in the X (staged) column") as u16; + assert_eq!( + row[x as usize + 1], + STATUS_PLACEHOLDER, + "expected the Y (worktree) column to be the empty placeholder, got: {:?}", + row + ); + 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" + ); + assert_eq!( + buf.cell((x + 1, y)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected the empty Y placeholder to carry theme.dim" + ); + } + + #[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). + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .partially_staged_file("a.rs", "one\n", "one\nSTAGED\n", "one\nSTAGED\nWORKTREE\n") + .build() + .unwrap(); + let (buf, y, row) = render_outline_file_row(&fixture, "a.rs"); + + let x = row + .iter() + .position(|&c| c == 'M') + .expect("expected the Modified letter in the X column") as u16; + assert_eq!( + row[x as usize + 1], + 'M', + "expected the Modified letter in the Y column too (partial = both axes), got: {:?}", + row + ); + 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" + ); + 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" + ); + } + + #[test] + fn outline_untracked_file_renders_a_dim_double_question_mark() { + // Untracked overrides the staged-ness-derived matrix entirely: always a dim `??`, even + // though an untracked worktree file's StagedStatus is Unstaged under the hood. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "brand new\n") + .build() + .unwrap(); + let (buf, y, row) = render_outline_file_row(&fixture, "new.txt"); + + let x = row + .iter() + .position(|&c| c == '?') + .expect("expected the untracked '??' marker") as u16; + assert_eq!( + row[x as usize + 1], + '?', + "expected '??' (both columns), got: {:?}", + row + ); + assert_eq!( + buf.cell((x, y)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected the untracked '?' to carry theme.dim, not an add/del tint" + ); + assert_eq!( + buf.cell((x + 1, y)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected BOTH untracked '?' chars to carry theme.dim" + ); + } + + #[test] + fn outline_committed_modified_file_renders_a_single_amber_letter() { + // A committed changeset's file has StagedStatus::None — single letter + pad column, not + // the X/Y matrix. M/R/C get the dedicated `modified_fg` amber, distinct from `warn_fg`. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("a.rs", "one\n") + .create("base") + .unwrap(); + let head = fixture + .commit("main") + .file("a.rs", "one\nCHANGED\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + let cs = workon::Changeset { + name: "cs".to_string(), + span: workon::ChangesetSpan::Committed { base, head }, + title: None, + current: true, + needs_restack: false, + }; + let view = crate::app::ChangesetView::from_changeset_diff( + cs.clone(), + crate::acquire::diff_changeset(repo, &cs).unwrap(), + ); + let owned = git2::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(); } let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); - let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); - // Skip y=0: the full-width winbar also names the file ("[1/1] a.rs"), so an unskipped - // search would match it instead of the outline's own row below it. - let row = content + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row_idx = content .iter() - .enumerate() - .skip(1) - .find(|(_, r)| r.contains("a.rs")) - .map(|(i, _)| i) + .position(|r| r.contains("a.rs")) .expect("a.rs's file row present"); - assert!( - content[row].contains('M'), - "expected the Modified change letter 'M' in a.rs's row, got: {:?}", - content[row] + let row: Vec = content[row_idx].chars().collect(); + let y = row_idx as u16 + 1; + + let x = row + .iter() + .position(|&c| c == 'M') + .expect("expected the Modified letter") as u16; + assert_eq!( + row[x as usize + 1], + ' ', + "expected the pad column after a committed file's single letter to be a blank space, \ + got: {:?}", + row + ); + assert_eq!( + buf.cell((x, y)).unwrap().style().fg, + Some(Palette::dark().modified_fg), + "expected the committed Modified letter to carry theme.modified_fg (amber), got a \ + different color" + ); + assert_ne!( + buf.cell((x, y)).unwrap().style().fg, + Some(Palette::dark().warn_fg), + "modified_fg must stay a distinct field from warn_fg even though both default to amber" ); + } - let letter_x = content[row].find('M').unwrap() as u16; + #[test] + fn outline_committed_added_and_deleted_files_render_add_strong_and_del_strong() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("deleted.txt", "keep\n") + .create("base") + .unwrap(); + let stage = fixture + .commit("main") + .file("deleted.txt", "keep\n") + .file("added.txt", "new\n") + .create("stage") + .unwrap(); + let _ = stage; // only needed to move the branch tip forward before the manual deletion below + let repo = fixture.repo().unwrap(); + let workdir = repo.workdir().unwrap().to_path_buf(); + std::fs::remove_file(workdir.join("deleted.txt")).unwrap(); + let mut index = repo.index().unwrap(); + // `CommitBuilder::create` wrote the index/commit through its OWN `Repository::open` + // handle, so `repo`'s cached index is stale until forced to re-read from disk. + index.read(true).unwrap(); + index + .remove_path(std::path::Path::new("deleted.txt")) + .unwrap(); + index.write().unwrap(); + let tree = repo.find_tree(index.write_tree().unwrap()).unwrap(); + let sig = git2::Signature::now("Test User", "test@example.com").unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + let head = repo + .commit(Some("HEAD"), &sig, &sig, "head", &tree, &[&parent]) + .unwrap(); + + let cs = workon::Changeset { + name: "cs".to_string(), + span: workon::ChangesetSpan::Committed { base, head }, + title: None, + current: true, + needs_restack: false, + }; + let view = crate::app::ChangesetView::from_changeset_diff( + cs.clone(), + crate::acquire::diff_changeset(repo, &cs).unwrap(), + ); + let owned = git2::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(); + } + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + + let added_row_idx = content + .iter() + .position(|r| r.contains("added.txt")) + .expect("added.txt's file row present"); + let added_row: Vec = content[added_row_idx].chars().collect(); + let added_x = added_row + .iter() + .position(|&c| c == 'A') + .expect("expected the Added letter") as u16; assert_eq!( - buf.cell((letter_x, row as u16)).unwrap().style().fg, - Some(Palette::dark().foreground), - "Modified is a change-to-existing-content status, so its letter must carry the \ - theme's neutral foreground, not an add/del tint" + buf.cell((added_x, added_row_idx as u16 + 1)) + .unwrap() + .style() + .fg, + Some(Palette::dark().add_strong), + "expected a committed Added file's letter to carry theme.add_strong" + ); + + let deleted_row_idx = content + .iter() + .position(|r| r.contains("deleted.txt")) + .expect("deleted.txt's file row present"); + let deleted_row: Vec = content[deleted_row_idx].chars().collect(); + let deleted_x = deleted_row + .iter() + .position(|&c| c == 'D') + .expect("expected the Deleted letter") as u16; + assert_eq!( + buf.cell((deleted_x, deleted_row_idx as u16 + 1)) + .unwrap() + .style() + .fg, + Some(Palette::dark().del_strong), + "expected a committed Deleted file's letter to carry theme.del_strong" ); } @@ -4132,7 +4466,12 @@ mod tests { } #[test] - fn outline_file_status_nerd_glyph_replaces_the_plain_glyph() { + fn outline_file_status_xy_column_is_unaffected_by_icon_mode() { + // CS3 retires StagedStatus's nerd/plain glyph split entirely — the X/Y status matrix is + // now plain letters + `STATUS_PLACEHOLDER`, icon-mode-independent (only the devicons + // per-file icon toggles on `IconMode::Nerd`). A fully staged file (`staged_file` writes a + // brand-new path, so it's Added, not Modified) still renders `A·` whether or not nerd + // icons are on. let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .staged_file("a.txt", "one\nCHANGED\n") @@ -4145,16 +4484,14 @@ mod tests { } let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); - let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); - let joined = content.join("\n"); - - assert!( - joined.contains(crate::outline::StagedStatus::Staged.nerd_glyph()), - "expected the nerd staged glyph (fa-check), got:\n{joined}" - ); + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row = content + .iter() + .find(|r| r.contains("a.txt")) + .expect("a.txt's file row present"); assert!( - !joined.contains(crate::outline::StagedStatus::Staged.glyph()), - "nerd mode must not leave the plain ✓ glyph behind, got:\n{joined}" + row.contains(&format!("A{}", '\u{b7}')), + "expected the fully-staged 'A·' status pair to survive nerd icon mode, got: {row:?}" ); } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index b126cc2..8721a13 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -31,6 +31,13 @@ //! semantic-chrome field, same reasoning and same three-scheme mapping as the CS2 trio above — //! it's the outline's changeset-header-row accent, used only there (see //! `render::changeset_title_spans`'s doc comment for the outline-only gating). +//! +//! **CS3 addition (`outline-status-xy`):** [`Palette::modified_fg`] (base09, orange/amber) is a +//! fifth semantic-chrome field, same three-scheme mapping again — the outline's committed-file +//! "modified" tint (M/R/C letters). Deliberately a NEW field rather than reusing +//! [`Palette::warn_fg`]: "this changeset needs restacking" and "this file was modified" are +//! unrelated facts that happen to both want an amber tone, and collapsing them onto one field +//! would make them un-independently themeable. use ratatui::style::Color; @@ -235,6 +242,12 @@ pub struct Palette { /// rows (`render::changeset_title_spans`'s `counter` param gates it) — the summary panel's /// changeset title keeps the plain [`Palette::foreground`] look. pub heading_fg: Color, + /// Tone for a committed changeset's Modified/Renamed/Copied outline file-status letter (CS3, + /// `outline-status-xy`) — an amber (base09), distinct from [`Palette::warn_fg`]'s amber + /// (base0A) so "needs restack" and "modified" stay independently themeable even though both + /// default to the same amber family. Used ONLY by the outline's committed-file status column + /// (`render::committed_letter_color`). + pub modified_fg: Color, /// Whether [`crate::render::render`] should paint the whole frame with [`Palette::background`] /// before drawing panes. `true` for the curated [`Palette::dark`]/[`Palette::light`] schemes /// (and the probe's curated fallback); `false` for [`Palette::from_terminal`], so `auto` @@ -278,6 +291,9 @@ impl Palette { // CS1: brand new (no historical `render.rs` const to reproduce), so this takes the // scheme's base0C directly rather than an authored literal. heading_fg: base.slot(12), + // CS3: brand new, same reasoning as `heading_fg` above — takes the scheme's base09 + // directly rather than an authored literal. + modified_fg: base.slot(9), paint_canvas: true, } } @@ -331,6 +347,7 @@ impl Palette { warn_fg: base.slot(10), // base0A current_fg: green, heading_fg: cyan, + modified_fg: base.slot(9), // base09 paint_canvas: true, } } @@ -375,6 +392,7 @@ impl Palette { warn_fg: base.slot(10), current_fg: base.slot(11), heading_fg: base.slot(12), + modified_fg: base.slot(9), // Unlike the curated schemes, `auto` must NOT paint over the terminal's own // background — base00 here IS the probed terminal bg, so painting a solid canvas // would defeat terminal transparency/background images for no benefit (the probed @@ -466,6 +484,19 @@ mod tests { assert_eq!(t.heading_fg, Color::Rgb(0x66, 0xcc, 0xcc)); // base0C } + #[test] + fn dark_modified_fg_takes_the_eighties_dark_orange_accent() { + // CS3: no historical constant to reproduce (this field is new, same reasoning as + // `dark_heading_fg_takes_the_eighties_dark_cyan_accent` above) — takes base09 straight + // from the scheme. + let t = Palette::dark(); + assert_eq!(t.modified_fg, Color::Rgb(0xf9, 0x91, 0x57)); // base09 + assert_ne!( + t.modified_fg, t.warn_fg, + "modified_fg must stay independently themeable from warn_fg" + ); + } + #[test] fn dark_chrome_fields_match_the_eighties_dark_ramp_and_paint_the_canvas() { // `dark()`'s canvas/chrome must come from the SAME ramp `Palette::dark`'s syntax/tints @@ -585,6 +616,16 @@ mod tests { assert_eq!(t.heading_fg, Color::Rgb(0x01, 0x84, 0xbc)); // base0C } + #[test] + fn light_modified_fg_takes_one_lights_orange_accent() { + let t = Palette::light(); + assert_eq!(t.modified_fg, Color::Rgb(0xd7, 0x5f, 0x00)); // base09 + assert_ne!( + t.modified_fg, t.warn_fg, + "modified_fg must stay independently themeable from warn_fg" + ); + } + /// A synthetic probed scheme with a distinct value in every slot and the given `base00`, so a /// test can assert `from_terminal`'s syntax slots came from the probed scheme (not a curated /// one) and read the base00 luminance branch. @@ -668,6 +709,7 @@ mod tests { assert_eq!(palette.warn_fg, probed.slot(10)); assert_eq!(palette.current_fg, probed.slot(11)); assert_eq!(palette.heading_fg, probed.slot(12)); + assert_eq!(palette.modified_fg, probed.slot(9)); assert_ne!(palette.error_fg, Palette::dark().error_fg); } From 509c04f9d3696b5bd38223a7f52820f803a7081d Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 13 Jul 2026 15:19:04 -0400 Subject: [PATCH 135/203] feat(review): reorder outline cycle and show next mode in footer --- git-workon-review/src/app.rs | 9 ++-- git-workon-review/src/keymap.rs | 72 ++++++++++++++++++++++++++++---- git-workon-review/src/outline.rs | 31 +++++++++----- git-workon-review/src/render.rs | 69 +++++++++++++++++++++++++----- 4 files changed, 149 insertions(+), 32 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 9eda4bb..49627fe 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -9208,10 +9208,13 @@ mod tests { app.outline.mode = OutlineMode::Stack; app.outline_cycle_mode(); - assert_eq!(app.outline_mode(), OutlineMode::Tree); + assert_eq!(app.outline_mode(), OutlineMode::StackTree); app.outline_cycle_mode(); - assert_eq!(app.outline_mode(), OutlineMode::StackTree); + assert_eq!(app.outline_mode(), OutlineMode::Flat); + + app.outline_cycle_mode(); + assert_eq!(app.outline_mode(), OutlineMode::Tree); } /// A single committed changeset touching two files under `src/`, for the Dir-row no-op @@ -9482,7 +9485,7 @@ mod tests { "precondition: scrolled away from the top" ); - app.outline_cycle_mode(); // -> Tree + app.outline_cycle_mode(); // -> StackTree let cursor = app.outline_cursor(); let scroll = app.outline_scroll(); assert!( diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 842a546..008c795 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -25,6 +25,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::config::{RawBinding, View}; +use crate::outline::OutlineMode; /// One rebindable action. The action *identity* — distinct from `tui.rs`'s `Action`, which is the /// concrete effect applied to the `App` (and carries runtime data like a half-page scroll delta @@ -334,7 +335,8 @@ pub static REGISTRY: &[Registered] = &[ view: View::Outline, name: "cycle-mode", default_keys: "i", - description: "Cycle the outline mode", + description: + "Cycle the outline mode (stack \u{25b8} stack-tree \u{25b8} flat \u{25b8} tree)", }, Registered { command: Command::FocusDiff, @@ -806,9 +808,17 @@ enum HintItem { Pair(Command, Command, &'static str), } -fn render_hint_item(keymap: &Keymap, item: &HintItem) -> Option { +/// CS4 (`outline-mode-cycle`): most hint labels are the static string baked into the `HintItem`, +/// but `OutlineCycleMode`'s label shows the mode `i` would switch TO instead — computed from +/// `outline_mode` (the outline's CURRENT mode, so this is `outline_mode.cycle()`'s label). +fn render_hint_item(keymap: &Keymap, item: &HintItem, outline_mode: OutlineMode) -> Option { match item { HintItem::One(command, label) => { + let label = if *command == Command::OutlineCycleMode { + format!("\u{2192}{}", outline_mode.cycle().label()) + } else { + (*label).to_string() + }; primary_key(keymap, *command).map(|k| format!("{k} {label}")) } HintItem::Pair(down, up, label) => { @@ -848,7 +858,7 @@ const OUTLINE_HINTS: &[HintItem] = &[ /// string, so a rebind shows here too. A notice temporarily replaces this in the footer (the /// caller's job, see `render::render_footer`); an unbound curated action is simply dropped from /// the string rather than leaving a stale/wrong key visible. -pub fn footer_hint(keymap: &Keymap, focused: View) -> String { +pub fn footer_hint(keymap: &Keymap, focused: View, outline_mode: OutlineMode) -> String { let items: &[HintItem] = match focused { View::Diff => DIFF_HINTS, View::Outline => OUTLINE_HINTS, @@ -856,7 +866,7 @@ pub fn footer_hint(keymap: &Keymap, focused: View) -> String { }; items .iter() - .filter_map(|item| render_hint_item(keymap, item)) + .filter_map(|item| render_hint_item(keymap, item, outline_mode)) .collect::>() .join(" \u{b7} ") } @@ -1228,6 +1238,29 @@ mod tests { assert_eq!(outline_sections[1].title, "Outline"); } + #[test] + fn help_sections_cycle_mode_entry_spells_out_the_full_order() { + // CS4: descriptions are static `&'static str`s baked into `REGISTRY`, so the help + // overlay can't mark the CURRENT mode dynamically without a broader refactor — the + // locked fallback is a static full-order description, with the dynamic `→next` shown + // only in the footer hint (see `footer_hint_outline_cycle_label_tracks_the_current_mode`). + let km = Keymap::defaults(); + let sections = help_sections(&km, View::Outline); + let outline = §ions[1]; + let entry = outline + .entries + .iter() + .find(|e| e.description.contains("Cycle the outline mode")) + .expect("cycle-mode row present"); + assert!( + entry + .description + .contains("stack \u{25b8} stack-tree \u{25b8} flat \u{25b8} tree"), + "got: {:?}", + entry.description + ); + } + #[test] fn help_sections_skip_an_unbound_action() { let km = Keymap::from_bindings(&[RawBinding { @@ -1266,7 +1299,7 @@ mod tests { #[test] fn footer_hint_renders_the_curated_diff_entries() { let km = Keymap::defaults(); - let hint = footer_hint(&km, View::Diff); + let hint = footer_hint(&km, View::Diff, OutlineMode::default()); assert!(hint.contains("j/k move"), "got: {hint:?}"); assert!(hint.contains("s stage"), "got: {hint:?}"); assert!(hint.contains("d discard"), "got: {hint:?}"); @@ -1278,10 +1311,31 @@ mod tests { #[test] fn footer_hint_renders_the_curated_outline_entries() { let km = Keymap::defaults(); - let hint = footer_hint(&km, View::Outline); + let hint = footer_hint(&km, View::Outline, OutlineMode::Stack); assert!(hint.contains("j/k move"), "got: {hint:?}"); assert!(hint.contains("enter open"), "got: {hint:?}"); - assert!(hint.contains("i mode"), "got: {hint:?}"); + assert!( + hint.contains("i \u{2192}stack-tree"), + "cycling from Stack must show the next mode, StackTree; got: {hint:?}" + ); + } + + #[test] + fn footer_hint_outline_cycle_label_tracks_the_current_mode() { + let km = Keymap::defaults(); + for (mode, next) in [ + (OutlineMode::Stack, "stack-tree"), + (OutlineMode::StackTree, "flat"), + (OutlineMode::Flat, "tree"), + (OutlineMode::Tree, "stack"), + ] { + let hint = footer_hint(&km, View::Outline, mode); + let want = format!("i \u{2192}{next}"); + assert!( + hint.contains(&want), + "mode {mode:?} should hint the NEXT mode {next:?}; got: {hint:?}" + ); + } } #[test] @@ -1291,7 +1345,7 @@ mod tests { action: "stage-hunk".to_string(), keys: "x".to_string(), }]); - let hint = footer_hint(&km, View::Diff); + let hint = footer_hint(&km, View::Diff, OutlineMode::default()); assert!(hint.contains("x stage"), "got: {hint:?}"); assert!(!hint.contains("s stage"), "got: {hint:?}"); } @@ -1303,7 +1357,7 @@ mod tests { action: "stage-hunk".to_string(), keys: String::new(), }]); - let hint = footer_hint(&km, View::Diff); + let hint = footer_hint(&km, View::Diff, OutlineMode::default()); assert!(!hint.contains("stage"), "got: {hint:?}"); // The rest of the curated set is unaffected. assert!(hint.contains("d discard"), "got: {hint:?}"); diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 79c9361..75640ed 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -37,16 +37,27 @@ pub enum OutlineMode { } impl OutlineMode { - /// `i`'s cycle order: `Flat -> Stack -> Tree -> StackTree -> Flat`. Flat/Stack (the - /// non-trie modes) come first since they're the CS3 default pair; the trie modes follow in - /// the same flat/grouped pairing (Tree mirrors Flat's cross-stack dedup, StackTree mirrors - /// Stack's per-changeset grouping). + /// `i`'s cycle order: `Stack -> StackTree -> Flat -> Tree -> Stack` (CS4) — the default + /// [`Self::Stack`] leads, its trie sibling [`Self::StackTree`] follows immediately, then the + /// non-grouped pair [`Self::Flat`]/[`Self::Tree`] closes the loop. pub fn cycle(self) -> Self { match self { - OutlineMode::Flat => OutlineMode::Stack, - OutlineMode::Stack => OutlineMode::Tree, - OutlineMode::Tree => OutlineMode::StackTree, + OutlineMode::Stack => OutlineMode::StackTree, OutlineMode::StackTree => OutlineMode::Flat, + OutlineMode::Flat => OutlineMode::Tree, + OutlineMode::Tree => OutlineMode::Stack, + } + } + + /// 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 + /// two never drift apart. + pub fn label(self) -> &'static str { + match self { + OutlineMode::Stack => "stack", + OutlineMode::StackTree => "stack-tree", + OutlineMode::Flat => "flat", + OutlineMode::Tree => "tree", } } } @@ -787,10 +798,10 @@ mod tests { #[test] fn mode_cycle_round_trips_all_four_modes() { - assert_eq!(OutlineMode::Flat.cycle(), OutlineMode::Stack); - assert_eq!(OutlineMode::Stack.cycle(), OutlineMode::Tree); - assert_eq!(OutlineMode::Tree.cycle(), OutlineMode::StackTree); + assert_eq!(OutlineMode::Stack.cycle(), OutlineMode::StackTree); assert_eq!(OutlineMode::StackTree.cycle(), OutlineMode::Flat); + assert_eq!(OutlineMode::Flat.cycle(), OutlineMode::Tree); + assert_eq!(OutlineMode::Tree.cycle(), OutlineMode::Stack); } /// Deep-path fixture used by the tree-mode tests: a top-level file, a top-level directory diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 878e10d..caf4761 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -1203,7 +1203,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, them } else { View::Diff }; - let text = footer_hint(keymap, focused); + let text = footer_hint(keymap, focused, app.outline_mode()); frame.render_widget( Paragraph::new(text).style(Style::default().fg(theme.dim)), area, @@ -2733,8 +2733,48 @@ mod tests { .map(|x| cell_text(&buf, x, footer_y)) .collect(); assert!( - footer.contains("open") && footer.contains("mode") && footer.contains("? help"), - "expected the curated outline hint string in the footer, got: {footer:?}" + footer.contains("open") + && footer.contains(&format!( + "i \u{2192}{}", + crate::outline::OutlineMode::StackTree.label() + )) + && footer.contains("? help"), + "expected the curated outline hint string, with CS4's dynamic next-mode label \ + (Stack's default -> StackTree), in the footer, got: {footer:?}" + ); + } + + #[test] + fn footer_outline_hint_next_mode_label_updates_as_the_mode_cycles() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.toggle_outline(); + assert!(app.outline_focused()); + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Stack); + + let footer_text = |app: &mut App| { + let buf = render_once(app, 80, 10); + let footer_y = buf.area.height - 1; + (0..buf.area.width) + .map(|x| cell_text(&buf, x, footer_y)) + .collect::() + }; + + let footer = footer_text(&mut app); + assert!( + footer.contains("i \u{2192}stack-tree"), + "Stack's next mode is StackTree; got: {footer:?}" + ); + + app.outline_cycle_mode(); + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::StackTree); + let footer = footer_text(&mut app); + assert!( + footer.contains("i \u{2192}flat"), + "StackTree's next mode is Flat; got: {footer:?}" ); } @@ -3638,8 +3678,7 @@ mod tests { .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); - app.outline_cycle_mode(); // Stack -> Tree - app.outline_cycle_mode(); // Tree -> StackTree + app.outline_cycle_mode(); // Stack -> StackTree app.outline_cycle_mode(); // StackTree -> Flat assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Flat); @@ -3716,7 +3755,9 @@ mod tests { if !app.outline_open() { app.toggle_outline(); } - app.outline_cycle_mode(); // Stack -> Tree + app.outline_cycle_mode(); // Stack -> StackTree + app.outline_cycle_mode(); // StackTree -> Flat + app.outline_cycle_mode(); // Flat -> Tree assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); @@ -3782,7 +3823,9 @@ mod tests { if !app.outline_open() { app.toggle_outline(); } - app.outline_cycle_mode(); // Stack -> Tree + app.outline_cycle_mode(); // Stack -> StackTree + app.outline_cycle_mode(); // StackTree -> Flat + app.outline_cycle_mode(); // Flat -> Tree assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); @@ -4366,7 +4409,9 @@ mod tests { if !app.outline_open() { app.toggle_outline(); } - app.outline_cycle_mode(); // Stack -> Tree, so `src/` renders as its own Dir row + app.outline_cycle_mode(); // Stack -> StackTree + app.outline_cycle_mode(); // StackTree -> Flat + app.outline_cycle_mode(); // Flat -> Tree, so `src/` renders as its own Dir row assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); app.set_icon_mode(crate::icons::IconMode::Nerd); @@ -4405,7 +4450,9 @@ mod tests { if !app.outline_open() { app.toggle_outline(); } - app.outline_cycle_mode(); // Stack -> Tree + app.outline_cycle_mode(); // Stack -> StackTree + app.outline_cycle_mode(); // StackTree -> Flat + app.outline_cycle_mode(); // Flat -> Tree assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); assert_eq!( app.icon_mode(), @@ -4504,7 +4551,9 @@ mod tests { let mut app = changeset_with_nested_paths(&fixture); app.set_icon_mode(crate::icons::IconMode::Nerd); app.focus_outline(); // opens (a lone changeset defaults closed) and focuses - app.outline_cycle_mode(); // Stack -> Tree, so a Dir row exists to focus + 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() From 174cb3351f71a4ea5a7b6a8ed93109fc818312bd Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 13 Jul 2026 16:25:47 -0400 Subject: [PATCH 136/203] feat(review): collapse and expand outline headers and directories --- git-workon-review/src/app.rs | 415 ++++++++++++++++++++++++---- git-workon-review/src/keymap.rs | 2 +- git-workon-review/src/outline.rs | 446 ++++++++++++++++++++++++++++++- git-workon-review/src/render.rs | 154 ++++++++++- git-workon-review/src/tui.rs | 78 +++++- 5 files changed, 1019 insertions(+), 76 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 49627fe..45eaaec 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -9,7 +9,7 @@ //! handle so it can lazily read blob/worktree content per file as the user navigates to it, //! independent of whatever handle acquired the [`DiffModel`] it was built from. -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::path::Path; use git2::Repository; @@ -27,7 +27,9 @@ use crate::highlight::{lang_key_for_ext, FgSpan, TsHighlighter}; use crate::icons::IconMode; use crate::model::{DiffModel, FileChange, FileStatus, Hunk, LineKind}; use crate::ops; -use crate::outline::{self, OutlineChangeset, OutlineFile, OutlineItem, OutlineMode, OutlineOrder}; +use crate::outline::{ + self, FoldKey, OutlineChangeset, OutlineFile, OutlineItem, OutlineMode, OutlineOrder, +}; use crate::queue::{OpOutcome, StagingOp, StagingQueue}; use crate::refresh::{IndexSignature, RefreshCoordinator}; use crate::scope::enclosing_scope_lines; @@ -813,6 +815,17 @@ pub struct OutlineState { /// here. Reset to `0` by [`App::outline_cycle_mode`] — the row list (and therefore the set of /// paths on screen) changes shape there, the same reason that resyncs the cursor. pub hscroll: usize, + /// CS5 (`outline-fold`): per-[`OutlineMode`] sets of collapsed [`FoldKey`]s — a Header row's + /// changeset label PLUS its `cs_idx`, or a Dir row's full path (+ owning changeset `cs_idx` in + /// `StackTree`) — see [`FoldKey`]'s own doc comment for why `cs_idx` is load-bearing there, + /// not decorative (a changeset's `label` alone can collide with its own uncommitted layer's). + /// Each mode keeps its own independent set (folding a dir in `Tree` doesn't affect + /// `StackTree`'s copy of the same path), survives mode cycling and auto-refresh (this lives on + /// `App`, not in the rebuilt-every-call row list), and starts empty — everything expanded by + /// default. Mutated only by [`App::outline_toggle_fold`]; never explicitly cleared, so a fold + /// outlives its own toggling row's disappearance and reappearance (e.g. a discard-then-recreate + /// of the same path) for as long as the session runs. + pub folds: HashMap>, } /// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the @@ -1378,6 +1391,7 @@ impl App { scroll: 0, order: OutlineOrder::default(), hscroll: 0, + folds: HashMap::new(), }; let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial @@ -2478,14 +2492,58 @@ impl App { .collect() } - /// Build the current [`OutlineMode`]'s row list — the outline cursor's index space, and the - /// source of truth `render.rs` draws from. Rebuilt fresh on every call (cheap: a small stack - /// times a handful of files each, no caching, same posture as [`Self::effective_zoom_for`]) - /// rather than cached on `App`, so it's never stale across a mode toggle, a nav, or a - /// refresh. + /// Build (via [`outline::fold_outline`]) the current [`OutlineMode`]'s FOLD-FILTERED row list + /// — the outline cursor's SINGLE index space, and the source of truth every other outline + /// consumer reads: `render.rs`, [`Self::outline_move_by`]/[`Self::outline_move_to`], + /// [`Self::outline_confirm`], [`Self::summary_target`], and the staging-verb resolution in + /// [`Self::outline_row_targets`] all funnel through this SAME method (CS5, `outline-fold`) — + /// so folding a Header/Dir can never silently retarget a cursor move or a stage/discard verb + /// onto the wrong row: there is no OTHER row list any of them could accidentally read + /// instead. Rebuilt fresh on every call (cheap: a small stack times a handful of files each, + /// no caching, same posture as [`Self::effective_zoom_for`]) rather than cached on `App`, so + /// it's never stale across a mode toggle, a nav, a fold, or a refresh. `render.rs`'s marker + /// needs the per-row hidden-file counts this discards — see + /// [`Self::outline_items_with_hidden_counts`]. pub fn outline_items(&self) -> Vec { + self.outline_folded().items + } + + /// [`Self::outline_items`], plus (aligned by index) each row's CS5 hidden-file marker count — + /// `render_outline`'s data source. Every OTHER outline consumer uses [`Self::outline_items`] + /// instead, which just discards the counts it doesn't need; both funnel through the same + /// [`Self::outline_folded`] build, so they can never disagree about which rows are visible. + pub fn outline_items_with_hidden_counts(&self) -> (Vec, Vec) { + let folded = self.outline_folded(); + (folded.items, folded.hidden_counts) + } + + /// The shared build [`Self::outline_items`]/[`Self::outline_items_with_hidden_counts`]/ + /// [`Self::outline_target_index`] all read from — [`outline::fold_outline`] applied to the + /// current mode/order/fold-set, so there's exactly one place that pairs "which changesets by + /// which state" with "the fold set for the CURRENT mode" (`self.outline.folds` is keyed by + /// [`OutlineMode`]; a mode with no folds recorded yet reads as "everything expanded", the + /// default). + fn outline_folded(&self) -> outline::FoldedOutline { let snapshot = self.outline_snapshot(); - outline::build_items(&snapshot, self.outline.mode, self.outline.order) + let folds = self.outline.folds.get(&self.outline.mode); + outline::fold_outline(&snapshot, self.outline.mode, self.outline.order, |key| { + folds.is_some_and(|set| set.contains(key)) + }) + } + + /// Resolve a target row matched against the FULL (unfiltered) row list to its position in + /// [`Self::outline_items`]'s FILTERED list — its own index if it's visible, or its nearest + /// visible (collapsed) ancestor's if a fold hides it (CS5's "`sync_outline_to_current` + /// targeting a file hidden under a collapsed node lands on the collapsed ancestor WITHOUT + /// auto-expanding" rule — see [`outline::FoldedOutline::visible_index`]'s doc comment). `find` + /// matches against the full build (via `outline::build_items` directly, not + /// [`Self::outline_items`]) since a fold-hidden target has no index in the filtered list at + /// all to match against. + fn outline_target_index(&self, find: impl Fn(&OutlineItem) -> bool) -> Option { + let snapshot = self.outline_snapshot(); + let full = outline::build_items(&snapshot, self.outline.mode, self.outline.order); + let full_idx = full.iter().position(find)?; + self.outline_folded().visible_index.get(full_idx).copied() } /// CS4: the outline row a Header/Dir cursor selection resolves to — `None` when the outline @@ -2706,10 +2764,14 @@ impl App { /// click landed in, matching the keyboard-driven equivalent for that region. Outline: focuses /// the outline and jumps the cursor to the clicked row via [`Self::outline_move_to`] — a File /// row jumps the diff there (same single-jump semantics `g`/`G` use), a Header/Dir row just - /// selects (the summary panel follows via [`Self::summary_target`]). Diff pane (single or - /// split): focuses that pane (flipping `split_focus` first if the click landed in the - /// unfocused half) and moves its cursor to the clicked row. Outside every recorded region - /// (header/footer/divider/captions): no-op. + /// selects (the summary panel follows via [`Self::summary_target`]) WITHOUT toggling its fold + /// (CS5, `outline-fold`) — a click has always been "move the cursor here", a strictly weaker + /// action than `Enter`'s "act on this row" even before folding existed (pre-CS5, `Enter` on a + /// Header jumped to its first file; a click on the same row never did), so a click staying + /// select-only here keeps that existing asymmetry rather than inventing a new "click mirrors + /// Enter" rule this pane never had. Diff pane (single or split): focuses that pane (flipping + /// `split_focus` first if the click landed in the unfocused half) and moves its cursor to the + /// clicked row. Outside every recorded region (header/footer/divider/captions): no-op. pub fn handle_click(&mut self, col: u16, row: u16) { let Some((pane, region)) = self.hit_test(col, row) else { return; @@ -2964,30 +3026,50 @@ impl App { self.outline_move_to(last); } - /// `Enter` while the outline has focus: jump the diff to the row under the outline cursor (a - /// file row jumps straight there; a header row jumps to that changeset's first file — the - /// one case [`Self::outline_move_by`] deliberately does NOT do on a bare cursor move), then - /// return focus to the diff. + /// `Enter` while the outline has focus: a FILE row jumps the diff straight there and returns + /// focus to the diff (unchanged since CS3). A HEADER or DIR row instead TOGGLES that row's + /// fold state (CS5, `outline-fold`) and deliberately does NOT return focus — you're + /// manipulating the outline's own structure, not confirming a jump, so there's nothing to + /// hand focus back to yet. This REMOVES Enter's pre-CS5 jump-to-changeset-first-file behavior + /// on a Header row (still reachable via Enter on any of that changeset's own file rows, or + /// `[c`/`]c`) and Dir's pre-CS5 no-op (CS4 shipped Dir rows before any fold state existed to + /// toggle). pub fn outline_confirm(&mut self) { let items = self.outline_items(); match items.get(self.outline.cursor) { Some(OutlineItem::File { cs_idx, file_idx, .. - }) => self.switch_changeset(*cs_idx, *file_idx), - Some(OutlineItem::Header { cs_idx, .. }) => { - let cs_idx = *cs_idx; - self.goto_changeset(cs_idx); - // `goto_changeset` is the shared outline/diff core and deliberately does not - // self-sync (see its doc comment) — this outline-initiated call syncs explicitly - // so the cursor follows off the header row onto the file it just jumped to. - self.sync_outline_to_current(); + }) => { + self.switch_changeset(*cs_idx, *file_idx); + self.outline.focused = false; + } + Some(OutlineItem::Header { .. } | OutlineItem::Dir { .. }) => { + self.outline_toggle_fold(); } - // A directory row (Tree/StackTree modes) is not a jump target — no expand/collapse - // state exists to toggle (CS4 decision), so Enter here is a no-op beyond the - // unconditional unfocus below, same as confirming on nothing at all. - Some(OutlineItem::Dir { .. }) | None => {} + None => self.outline.focused = false, } - self.outline.focused = false; + } + + /// `Enter` on a Header/Dir row (CS5, `outline-fold`): flip that row's collapsed state in the + /// CURRENT [`OutlineMode`]'s fold set (see [`OutlineState::folds`]), then re-derive the + /// outline scroll — the row list's length just changed shape (more/fewer rows), the same + /// reason every other row-count-changing op does. The cursor's own INDEX never needs + /// re-finding: toggling a row's fold only changes what's visible AFTER it in the list (its + /// descendants), never before, so the row under the cursor — the one just toggled — stays + /// exactly where it was. + fn outline_toggle_fold(&mut self) { + let items = self.outline_items(); + let Some(item) = items.get(self.outline.cursor) else { + return; + }; + let Some(key) = FoldKey::for_item(item) else { + return; + }; + let set = self.outline.folds.entry(self.outline.mode).or_default(); + if !set.remove(&key) { + set.insert(key); + } + self.derive_outline_scroll(self.outline_items().len()); } // ── Outline staging (CS7) ─────────────────────────────────────────────────── @@ -3227,20 +3309,21 @@ impl App { } /// Reposition (never rebuild/refocus) the outline cursor onto the row matching the CURRENT - /// diff changeset+file, or clamp it into bounds if no such row exists (e.g. Flat mode - /// deduped the current file's changeset out of the list). The sync-follow discipline's echo - /// break: called ONLY from the diff-initiated nav entry points (`next_file`/`prev_file`/ - /// `next_changeset`/`prev_changeset`/`refresh`, plus the two outline actions that explicitly - /// opt in after a header jump) — never from `switch_changeset`/`goto_changeset` themselves, - /// since those are the shared core an OUTLINE-initiated jump also calls, and an - /// outline-initiated jump has already set [`OutlineState::cursor`] to the row the user - /// selected. If this ran unconditionally inside `switch_changeset`, an outline `j`/`k` move - /// past a HEADER row (which never calls `switch_changeset`, so nothing would resync) would - /// be fine, but any accidental future call site wired into the shared core would instantly - /// stomp a manually-positioned outline cursor back onto the diff's last position — the exact - /// oscillation the prototype's `_suppress_sync` flag existed to prevent. Keeping the sync - /// calls only at the diff-facing entry points achieves the same break without needing a - /// mutable suppression flag on `App`. + /// diff changeset+file — or, if a fold hides that row, its nearest visible (collapsed) + /// ancestor instead, WITHOUT auto-expanding it (CS5, `outline-fold` — preserves the user's + /// fold intent; see [`Self::outline_target_index`]) — or clamps into bounds if no such row + /// exists in the FULL build at all (e.g. Flat mode deduped the current file's changeset out + /// of the list entirely). The sync-follow discipline's echo break: called ONLY from the + /// diff-initiated nav entry points (`next_file`/`prev_file`/`next_changeset`/`prev_changeset`/ + /// `refresh`) — never from `switch_changeset`/`goto_changeset` themselves, since those are the + /// shared core an OUTLINE-initiated jump also calls, and an outline-initiated jump has already + /// set [`OutlineState::cursor`] to the row the user selected. If this ran unconditionally + /// inside `switch_changeset`, an outline `j`/`k` move past a HEADER row (which never calls + /// `switch_changeset`, so nothing would resync) would be fine, but any accidental future call + /// site wired into the shared core would instantly stomp a manually-positioned outline cursor + /// back onto the diff's last position — the exact oscillation the prototype's + /// `_suppress_sync` flag existed to prevent. Keeping the sync calls only at the diff-facing + /// entry points achieves the same break without needing a mutable suppression flag on `App`. fn sync_outline_to_current(&mut self) { let items = self.outline_items(); if items.is_empty() { @@ -3248,11 +3331,13 @@ impl App { self.derive_outline_scroll(0); return; } - if let Some(idx) = items.iter().position(|it| { + let current_cs = self.current_cs; + let current = self.current; + if let Some(idx) = self.outline_target_index(|it| { matches!( it, OutlineItem::File { cs_idx, file_idx, .. } - if *cs_idx == self.current_cs && *file_idx == self.current + if *cs_idx == current_cs && *file_idx == current ) }) { self.outline.cursor = idx; @@ -9123,28 +9208,48 @@ mod tests { } #[test] - fn outline_confirm_on_a_header_row_jumps_to_its_first_file_and_returns_focus() { + fn outline_confirm_on_a_header_row_toggles_fold_instead_of_jumping_and_keeps_focus() { + // CS5 (`outline-fold`) removes Enter's pre-CS5 jump-to-changeset-first-file behavior on a + // Header row — it now toggles that row's fold instead, and deliberately does NOT return + // focus (you're manipulating the outline, not confirming a jump). let mut app = two_committed_changesets_two_and_one_files(); app.outline.mode = OutlineMode::Stack; // CS3: pin BaseFirst explicitly — cursor 3 is hardcoded to cs-b's header under base -> - // head row order; the confirm mechanic under test is order-agnostic. + // head row order; the toggle mechanic under test is order-agnostic. app.outline.order = OutlineOrder::BaseFirst; app.outline.open = true; app.outline.focused = true; app.outline.cursor = 3; // cs-b's header row + let before_cs = app.current_cs(); + let before_file = app.current; + let rows_before = app.outline_items().len(); app.outline_confirm(); assert_eq!( app.current_cs(), - 1, - "Enter on a header must jump to that changeset" + before_cs, + "Enter on a header must NOT jump the diff (CS5)" ); - assert_eq!(app.current, 0, "...landing on its FIRST file"); + assert_eq!(app.current, before_file); assert!( - !app.outline_focused(), - "confirming returns focus to the diff" + app.outline_focused(), + "toggling a fold must NOT return focus to the diff" + ); + assert_eq!( + app.outline_items().len(), + rows_before - 1, + "cs-b's single file row is now hidden under its collapsed header" + ); + assert_eq!( + app.outline_cursor(), + 3, + "the cursor stays on the header row it just toggled" ); + + // Toggling again expands it back. + app.outline_confirm(); + assert_eq!(app.outline_items().len(), rows_before); } #[test] @@ -9279,6 +9384,7 @@ mod tests { app.outline.cursor = dir_idx; app.outline.focused = true; + let rows_before = app.outline_items().len(); app.outline_confirm(); assert_eq!(app.current_cs(), before_cs); assert_eq!( @@ -9286,8 +9392,12 @@ mod tests { "confirming a Dir row must not jump the diff" ); assert!( - !app.outline_focused(), - "confirm still returns focus to the diff, even as a no-op" + app.outline_focused(), + "confirming a Dir row toggles its fold (CS5) rather than returning focus" + ); + assert!( + app.outline_items().len() < rows_before, + "src/'s files must now be hidden under its collapsed row" ); } @@ -10213,6 +10323,203 @@ mod tests { } } + // ── CS5 (`outline-fold`): collapse/expand ─────────────────────────────────── + + #[test] + fn outline_toggle_fold_hides_the_headers_files_and_move_by_skips_them() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + // Row order (BaseFirst): [Header cs-a, File a1, File a2, Header cs-b, File b1]. + let rows_before = app.outline_items().len(); + assert_eq!(rows_before, 5); + + app.outline.cursor = 3; // cs-b's header + app.outline_confirm(); // toggle fold + let items = app.outline_items(); + assert_eq!(items.len(), 4, "cs-b's single file row is now hidden"); + assert!( + items + .iter() + .all(|it| !matches!(it, OutlineItem::File { cs_idx: 1, .. })), + "no cs-b file row should be reachable while its header is collapsed" + ); + + // `j` from the last visible row (now the folded header, index 3) must clamp there — there + // is nothing further to move onto. + app.outline.cursor = 3; + app.outline_move_by(5); + assert_eq!( + app.outline.cursor, 3, + "the cursor clamps at the collapsed header — b1.txt's row isn't in the index space \ + to land on at all" + ); + } + + #[test] + fn outline_toggle_fold_expanding_again_restores_every_row() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + let rows_before = app.outline_items().len(); + + app.outline.cursor = 3; + app.outline_confirm(); // collapse + assert!(app.outline_items().len() < rows_before); + app.outline_confirm(); // expand again + assert_eq!( + app.outline_items(), + { + app.outline.folds.clear(); + app.outline_items() + }, + "re-expanding must reproduce exactly the same rows an empty fold set would" + ); + } + + #[test] + fn outline_fold_state_is_independent_per_mode() { + // Folding cs-b's header in Stack mode must not affect StackTree's own (separate) fold + // set, even though both modes emit a Header row keyed by the SAME label. + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + app.outline.cursor = 3; // cs-b's header in Stack mode + app.outline_confirm(); + assert!( + app.outline + .folds + .get(&OutlineMode::Stack) + .is_some_and(|s| !s.is_empty()), + "Stack mode's own fold set recorded the toggle" + ); + + app.outline.mode = OutlineMode::StackTree; + assert!( + app.outline + .folds + .get(&OutlineMode::StackTree) + .is_none_or(|s| s.is_empty()), + "StackTree mode must start with its OWN empty fold set, untouched by Stack mode's" + ); + let stack_tree_items = app.outline_items(); + assert!( + stack_tree_items + .iter() + .any(|it| matches!(it, OutlineItem::File { cs_idx: 1, .. })), + "cs-b's file row must still be visible in StackTree mode — Stack mode's fold doesn't \ + leak across modes" + ); + } + + #[test] + fn sync_outline_to_current_lands_on_the_collapsed_ancestor_without_auto_expanding() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + let header_b = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::Header { cs_idx: 1, .. })) + .expect("cs-b's header present"); + app.outline.cursor = header_b; + app.outline_confirm(); // collapse cs-b's header + assert!( + app.outline_focused(), + "toggling a fold keeps focus (CS5) — sanity for the nav below" + ); + + // A diff-initiated nav lands the diff on cs-b's (now-hidden) first file. + app.next_changeset(); + assert_eq!(app.current_cs(), 1, "the diff itself did jump to cs-b"); + + let folded_header_idx = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::Header { cs_idx: 1, .. })) + .expect("cs-b's collapsed header row still present"); + assert_eq!( + app.outline_cursor(), + folded_header_idx, + "the outline cursor must land on cs-b's collapsed header row, not an arbitrary clamp" + ); + assert!( + app.outline + .folds + .get(&OutlineMode::Stack) + .is_some_and(|s| !s.is_empty()), + "landing on the collapsed ancestor must NOT auto-expand it" + ); + } + + #[test] + fn outline_stage_targets_the_correct_row_when_an_unrelated_header_is_folded() { + // The highest-risk CS5 interaction: folding one changeset's header shifts every LATER + // row's index in `outline_items()` — a stage/discard verb resolved against a stale + // (unfiltered) index space would silently act on the wrong file. `outline_stage` reads + // `outline_row_targets`, which reads `outline_items()` at the CURSOR's own index — the + // same fold-filtered list the cursor itself was placed against — so it must stay correct. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .unstaged_file("dirty.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + repo.set_head("refs/heads/a").unwrap(); + repo.checkout_head(None).unwrap(); + + let changesets = crate::acquire::resolve_changesets(repo, "a").unwrap(); + assert_eq!( + changesets.len(), + 2, + "expected the 'a' Graphite node plus the dirty tree's uncommitted layer" + ); + let diffs = crate::acquire::diff_changesets(repo, &changesets).unwrap(); + let views: Vec = changesets + .into_iter() + .zip(diffs) + .map(|(cs, diff)| ChangesetView::from_changeset_diff(cs, diff)) + .collect(); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, views); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + + // Fold the committed "a" node's header — hides its own file row, shifting dirty.txt's + // row index one earlier in the filtered list. + let header_a = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::Header { cs_idx: 0, .. })) + .expect("'a's header present"); + app.outline.cursor = header_a; + app.outline_confirm(); + + let dirty_idx = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::File { path, .. } if path == "dirty.txt")) + .expect("dirty.txt's row is still visible — its own header isn't folded"); + app.outline.cursor = dirty_idx; + + app.outline_stage(); + + assert!(app.notice.is_none(), "stage must succeed: {:?}", app.notice); + repo.assert(predicate::repo::has_staged_file("dirty.txt")); + } + // ── CS8: progressive gap expansion ────────────────────────────────────── /// A single-file fixture with two hunks separated by a wide (40-line) unchanged run — wide diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 008c795..7c5efaa 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -328,7 +328,7 @@ pub static REGISTRY: &[Registered] = &[ view: View::Outline, name: "open", default_keys: "enter", - description: "Jump to the selected outline entry", + description: "Jump to a file, or fold/unfold a header or directory", }, Registered { command: Command::OutlineCycleMode, diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 75640ed..1393f8b 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -12,6 +12,15 @@ //! `crate::model::FileStatus` keeps this module's pure-data posture intact: `model.rs` is itself //! a pure data module (no `App`/`ChangesetView` dependency), so importing its plain enum doesn't //! reintroduce the `App` coupling this module was factored out to avoid. +//! +//! CS5 (`outline-fold`) also adds a second stage layered on top of [`build_items`]: collapse/ +//! expand. [`build_items`] itself stays wholly unaware of fold state (its extensive mode/dedup/ +//! guide tests below are untouched by CS5) — [`apply_fold`] takes its output and a per-row +//! collapsed predicate and returns the filtered row list plus the two extra pieces of data render/ +//! cursor logic needs (a collapsed row's hidden-file count, and a full-list -> filtered-list index +//! map for re-finding a fold-hidden target). [`fold_outline`] is the two steps composed — +//! `App::outline_items`'s single entry point (see that method's doc comment for why every +//! cursor/staging/render consumer funnels through the SAME filtered list). use std::collections::HashMap; @@ -19,7 +28,7 @@ use crate::model::FileStatus; /// Which of the outline's row-building strategies is active — cycled by `i` (only while the /// outline pane has focus; see `App::outline_cycle_mode`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum OutlineMode { /// Every changed path across the whole stack, once each, no changeset headers. Flat, @@ -196,9 +205,10 @@ pub enum OutlineItem { }, /// A directory row — only emitted in [`OutlineMode::Tree`]/[`OutlineMode::StackTree`]. Not a /// jump target: it carries no `file_idx`, so `App::outline_move_by` no-ops on it (same as - /// [`Self::Header`]) and `App::outline_confirm` also no-ops on it (CS4 decision — there's no - /// expand/collapse state to toggle, so Enter on a directory row does nothing but still - /// returns focus to the diff, matching every other confirm outcome). + /// [`Self::Header`]); `App::outline_confirm` toggles this row's fold state instead of jumping + /// (CS5, `outline-fold`) and deliberately does NOT return focus to the diff — see that + /// method's doc comment. Fold state itself lives on `App` (per-[`OutlineMode`] sets keyed by + /// [`FoldKey`]), not here — this row stays a plain data snapshot either way. Dir { name: String, /// The FULL path from the trie root (e.g. `"src/cmd"`), unlike `name` which is just the @@ -248,7 +258,11 @@ impl OutlineItem { /// (that array's own base -> head storage order never changes) regardless of `order` — only the /// ROW SEQUENCE the outline paints flips. [`build_tree`]'s de-dupe is order-independent (see its /// own doc comment), so `order` is accepted but unused there. -pub fn build_items( +/// +/// `pub(crate)` (CS5): this is the "unfiltered build" [`fold_outline`]'s doc comment refers to — +/// every outside-the-module consumer (i.e. `App`) goes through `fold_outline`/`apply_fold` +/// instead, so a fold is never accidentally bypassed by calling this directly. +pub(crate) fn build_items( changesets: &[OutlineChangeset], mode: OutlineMode, order: OutlineOrder, @@ -261,6 +275,186 @@ pub fn build_items( } } +// ── Fold (collapse/expand), CS5 `outline-fold` ────────────────────────────────── + +/// A foldable outline row's identity — the key `App`'s per-[`OutlineMode`] fold sets store. +/// [`OutlineItem::Header`] is keyed by its changeset's label PLUS its `cs_idx`; [`OutlineItem::Dir`] +/// by its full path plus, in [`OutlineMode::StackTree`], its owning changeset's `cs_idx` (`None` +/// in [`OutlineMode::Tree`], mirroring [`OutlineItem::Dir::cs_idx`]'s own `Option` — that mode's +/// single trie has no one owning changeset to key by). +/// +/// `cs_idx` is load-bearing here, not just belt-and-suspenders: a changeset's own `label` is NOT +/// unique across a single snapshot — [`crate::acquire::uncommitted_changeset`] and Graphite's +/// `insert_uncommitted_layer` both name the synthetic uncommitted-layer changeset after the SAME +/// branch its committed node is named after (no title on either), so a branch's committed node +/// and its own uncommitted worktree layer are two DIFFERENT rows that render the identical label. +/// Keying by label alone would fold both together the moment either was toggled. `cs_idx` is +/// still the true index into `App::changesets` (stable across an ordinary refresh — only a +/// structural stack change, e.g. a changeset added/removed, shifts it), matching the same +/// "identity survives refresh via `cs_idx`" precedent [`crate::app::OutlineRowIdentity`] already +/// relies on for staging-verb restore. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum FoldKey { + Header { label: String, cs_idx: usize }, + Dir { path: String, owner: Option }, +} + +impl FoldKey { + /// `item`'s [`FoldKey`], or `None` for a [`OutlineItem::File`] row (never foldable — it + /// carries no fold state of its own). Reads only fields the item already carries on itself + /// (`cs_idx`, `label`/`path`) — no external lookup needed. `pub(crate)`: also + /// `App::outline_toggle_fold`'s way of turning "the row under the cursor" into the key its + /// fold set is keyed by, without duplicating this match. + pub(crate) fn for_item(item: &OutlineItem) -> Option { + match item { + OutlineItem::Header { cs_idx, label, .. } => Some(FoldKey::Header { + label: label.clone(), + cs_idx: *cs_idx, + }), + OutlineItem::Dir { path, cs_idx, .. } => Some(FoldKey::Dir { + path: path.clone(), + owner: *cs_idx, + }), + OutlineItem::File { .. } => None, + } + } +} + +/// The outline's row list after CS5's fold filtering is layered on top of [`build_items`]'s raw +/// build — see [`apply_fold`]/[`fold_outline`]'s doc comments for how it's derived, and +/// `App::outline_items`'s doc comment for why this is the SINGLE choke point every cursor/ +/// staging/render consumer reads through. +#[derive(Debug, Clone)] +pub(crate) struct FoldedOutline { + /// The visible rows, in order — a subsequence of [`build_items`]'s full (unfiltered) output. + pub items: Vec, + /// Parallel to `items`: the count of hidden FILE rows (not dirs — CS5's locked "N = hidden + /// FILE rows only" rule) under a collapsed Header/Dir row. `0` for every other row, including + /// an EXPANDED Header/Dir — render reads `0` as "no marker", so an expanded row never draws + /// the trailing ` ▸ N` chevron. + pub hidden_counts: Vec, + /// Parallel to the FULL (unfiltered) [`build_items`] output, NOT to `items`: for original row + /// `i`, the index into `items`/`hidden_counts` a cursor targeting that row should land on — + /// its own filtered position if it survived filtering, or its nearest VISIBLE ancestor's if a + /// fold hides it (CS5's "lands on the collapsed ancestor without auto-expanding" rule). Used + /// by `App::sync_outline_to_current` to re-target a diff-initiated jump onto a folded row's + /// row instead of leaving the outline cursor on an arbitrary clamp. + pub visible_index: Vec, +} + +/// Filter `items` (a fresh [`build_items`] call's output) down to the rows `is_folded`'s per-mode +/// fold set leaves visible, computing each collapsed row's hidden-file marker and the full-list -> +/// filtered-list index map described on [`FoldedOutline::visible_index`]. Needs no `changesets` +/// snapshot of its own — [`FoldKey::for_item`] reads only what each item already carries on +/// itself (see that fn's doc comment on why `cs_idx`, not a label lookup, is what disambiguates). +/// +/// One linear pass with an explicit stack of "open ancestor" frames, mirroring [`emit`]'s own +/// depth-first row order: a [`OutlineItem::Header`] frame's scope is "everything up to the next +/// Header" (depth `-1`, a sentinel shallower than every real tree depth); a [`OutlineItem::Dir`] +/// frame's scope is "everything with a deeper tree `guides` prefix than its own" (its +/// [`OutlineItem::depth`]). Both close the same way: popping frames whose recorded depth is `>=` +/// the current row's depth, since a shallower-or-equal row can't be that frame's descendant. A row +/// hidden by ANY currently-open ancestor being folded is dropped from the output entirely, but a +/// hidden File row still bumps every open ancestor's running hidden-file count (even an unfolded +/// one — that count is simply never read unless the frame turns out to be folded when it's +/// popped), so a doubly-nested fold's OUTER marker still counts files hidden two levels down. +pub(crate) fn apply_fold( + items: &[OutlineItem], + is_folded: impl Fn(&FoldKey) -> bool, +) -> FoldedOutline { + struct Frame { + depth: isize, + folded: bool, + hidden: usize, + /// Index into the output `items`/`hidden_counts` this frame's OWN row landed at — `None` + /// if the frame's own row was itself hidden by a still-further-out fold (a doubly-nested + /// collapse), in which case it never got a marker to write into. + out_idx: Option, + } + + /// Write a popped frame's final hidden-file count into its own row's marker slot — only if + /// the frame is folded (an expanded frame's count is dead data, never read) and was itself + /// visible (`out_idx: Some`; a hidden frame has no marker slot to write into at all). + fn finalize(frame: Frame, hidden_counts: &mut [usize]) { + if frame.folded { + if let Some(idx) = frame.out_idx { + hidden_counts[idx] = frame.hidden; + } + } + } + + let mut stack: Vec = Vec::new(); + let mut out_items: Vec = Vec::new(); + let mut hidden_counts: Vec = Vec::new(); + let mut visible_index: Vec = Vec::with_capacity(items.len()); + + for item in items { + let depth: isize = match item { + OutlineItem::Header { .. } => -1, + OutlineItem::Dir { .. } | OutlineItem::File { .. } => item.depth() as isize, + }; + while stack.last().is_some_and(|f| f.depth >= depth) { + finalize( + stack.pop().expect("just checked the stack is non-empty"), + &mut hidden_counts, + ); + } + + let hidden = stack.iter().any(|f| f.folded); + if hidden && matches!(item, OutlineItem::File { .. }) { + for f in &mut stack { + f.hidden += 1; + } + } + + let out_idx = + if hidden { + stack.iter().rev().find_map(|f| f.out_idx).expect( + "row 0 of any build is always visible, so some open ancestor must be too", + ) + } else { + let idx = out_items.len(); + out_items.push(item.clone()); + hidden_counts.push(0); + idx + }; + visible_index.push(out_idx); + + if let OutlineItem::Header { .. } | OutlineItem::Dir { .. } = item { + let key = FoldKey::for_item(item) + .expect("just matched Header/Dir, both of which always resolve a FoldKey"); + stack.push(Frame { + depth, + folded: is_folded(&key), + hidden: 0, + out_idx: if hidden { None } else { Some(out_idx) }, + }); + } + } + while let Some(frame) = stack.pop() { + finalize(frame, &mut hidden_counts); + } + + FoldedOutline { + items: out_items, + hidden_counts, + visible_index, + } +} + +/// [`build_items`] + [`apply_fold`] composed — `App::outline_items`'s (and its private +/// `App::outline_folded` helper's) single entry point, so `app.rs` never has to import both +/// functions and remember to always pair them. +pub(crate) fn fold_outline( + changesets: &[OutlineChangeset], + mode: OutlineMode, + order: OutlineOrder, + is_folded: impl Fn(&FoldKey) -> bool, +) -> FoldedOutline { + let items = build_items(changesets, mode, order); + apply_fold(&items, is_folded) +} + /// [`OutlineMode::Stack`]: a header per changeset, then its files in order — no de-duplication, /// every changeset's own copy of a path (if touched more than once across the stack) gets its /// own row under its own header. `order` picks which end of the stack paints first; `cs_idx`/ @@ -1051,4 +1245,246 @@ mod tests { "cs-b's own file follows immediately under its head-first header" ); } + + // ── Fold (collapse/expand), CS5 `outline-fold` ────────────────────────────── + + #[test] + fn apply_fold_with_nothing_folded_leaves_every_row_visible_with_zero_markers() { + let changesets = vec![ + cs("cs-a", false, false, &[("a1.txt", StagedStatus::None)]), + cs("cs-b", true, true, &[("b1.txt", StagedStatus::None)]), + ]; + let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst); + let folded = apply_fold(&items, |_| false); + assert_eq!( + folded.items, items, + "nothing folded, so nothing is filtered" + ); + assert!( + folded.hidden_counts.iter().all(|&n| n == 0), + "no collapsed row, so no marker anywhere" + ); + assert_eq!( + folded.visible_index, + (0..items.len()).collect::>(), + "every row maps onto its own (only) position" + ); + } + + #[test] + fn apply_fold_hides_a_collapsed_headers_files_and_marks_the_hidden_count() { + let changesets = vec![ + cs( + "cs-a", + false, + false, + &[ + ("a1.txt", StagedStatus::None), + ("a2.txt", StagedStatus::None), + ], + ), + cs("cs-b", true, false, &[("b1.txt", StagedStatus::None)]), + ]; + let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst); + let folded = apply_fold(&items, |key| { + *key == FoldKey::Header { + label: "cs-a".to_string(), + cs_idx: 0, + } + }); + assert_eq!( + folded.items.len(), + 3, + "cs-a's header survives (its 2 files hidden); cs-b's header AND its own file both \ + survive (cs-b isn't folded)" + ); + assert!(matches!( + folded.items[0], + OutlineItem::Header { ref label, .. } if label == "cs-a" + )); + assert_eq!( + folded.hidden_counts[0], 2, + "cs-a's collapsed header marks its 2 hidden files" + ); + assert!(matches!( + folded.items[1], + OutlineItem::Header { ref label, .. } if label == "cs-b" + )); + assert_eq!(folded.hidden_counts[1], 0, "cs-b is not collapsed"); + assert!(matches!( + folded.items[2], + OutlineItem::File { ref path, .. } if path == "b1.txt" + )); + } + + #[test] + fn apply_fold_leaves_a_sibling_headers_files_untouched() { + let changesets = vec![ + cs("cs-a", false, false, &[("a1.txt", StagedStatus::None)]), + cs("cs-b", true, false, &[("b1.txt", StagedStatus::None)]), + ]; + let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst); + let folded = apply_fold(&items, |key| { + *key == FoldKey::Header { + label: "cs-a".to_string(), + cs_idx: 0, + } + }); + let paths: Vec<&str> = folded + .items + .iter() + .filter_map(|it| match it { + OutlineItem::File { path, .. } => Some(path.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + paths, + vec!["b1.txt"], + "cs-b's own file stays visible; only cs-a's collapsed section is hidden" + ); + } + + #[test] + fn apply_fold_collapsing_a_dir_hides_its_nested_files_and_subdirs_but_counts_only_files() { + let changesets = vec![deep_path_changeset("cs-a", true, false)]; + let items = build_items(&changesets, OutlineMode::Tree, OutlineOrder::HeadFirst); + // `src` (depth 0) contains `src/a` (a nested dir, depth 1) and `src/d.rs`, and `src/a` + // itself contains `src/a/b.rs` + `src/a/c.rs` — collapsing `src` should hide all 3 files + // (b.rs, c.rs, d.rs) it contains at any depth, but the marker counts files only, not the + // nested `src/a` dir row itself. + let folded = apply_fold(&items, |key| { + *key == FoldKey::Dir { + path: "src".to_string(), + owner: None, + } + }); + assert_eq!( + folded.items.len(), + 2, + "src/ (collapsed) and top.rs (an unrelated sibling) survive" + ); + let src_idx = folded + .items + .iter() + .position(|it| matches!(it, OutlineItem::Dir { name, .. } if name == "src")) + .expect("src/ row survives collapsed"); + assert_eq!( + folded.hidden_counts[src_idx], 3, + "b.rs, c.rs, and d.rs are all hidden under collapsed src/ — src/a/ itself doesn't count" + ); + } + + #[test] + fn apply_fold_doubly_nested_collapse_still_counts_toward_the_outer_markers_hidden_files() { + let changesets = vec![deep_path_changeset("cs-a", true, false)]; + let items = build_items(&changesets, OutlineMode::Tree, OutlineOrder::HeadFirst); + // Collapse BOTH `src` and its nested `src/a` — `src/a`'s own row is hidden (nested inside + // the already-collapsed `src`), but its 2 files must still count toward `src`'s own + // marker, even though `src/a`'s marker is never written (it has no visible row to write + // into). + let folded = apply_fold(&items, |key| { + matches!( + key, + FoldKey::Dir { path, owner: None } if path == "src" || path == "src/a" + ) + }); + assert_eq!( + folded.items.len(), + 2, + "only src/ (collapsed) and top.rs survive; src/a/ is hidden under src/'s own fold" + ); + let src_idx = folded + .items + .iter() + .position(|it| matches!(it, OutlineItem::Dir { name, .. } if name == "src")) + .expect("src/ row survives collapsed"); + assert_eq!( + folded.hidden_counts[src_idx], 3, + "src/'s marker still counts all 3 descendant files, even the 2 nested two levels down \ + under the also-collapsed (and therefore invisible) src/a/" + ); + } + + #[test] + fn apply_fold_visible_index_maps_a_hidden_files_full_list_position_to_its_visible_ancestor() { + // `deep_path_changeset`'s full (unfolded) Tree-mode row order is exactly: + // [src/ (0), src/a/ (1), src/a/b.rs (2), src/a/c.rs (3), src/d.rs (4), top.rs (5)] — see + // `tree_mode_builds_dirs_before_files_alpha_within_group_with_correct_depth_and_guides` + // above, which pins this same order. Collapsing `src/` hides everything at indices 1..=4 + // (all nested under it, regardless of their own depth); index 5 (`top.rs`) is a sibling, + // untouched. + let changesets = vec![deep_path_changeset("cs-a", true, false)]; + let items = build_items(&changesets, OutlineMode::Tree, OutlineOrder::HeadFirst); + let folded = apply_fold(&items, |key| { + *key == FoldKey::Dir { + path: "src".to_string(), + owner: None, + } + }); + let src_visible_idx = folded + .items + .iter() + .position(|it| matches!(it, OutlineItem::Dir { name, .. } if name == "src")) + .expect("src/ survives collapsed"); + + assert_eq!( + folded.visible_index[0], src_visible_idx, + "src/'s own row maps onto itself" + ); + for (full_idx, item) in items.iter().enumerate().take(5).skip(1) { + assert_eq!( + folded.visible_index[full_idx], src_visible_idx, + "row {full_idx} ({item:?}) is hidden under collapsed src/, so it must map onto \ + src/'s own visible row" + ); + } + let top_rs_visible_idx = folded + .items + .iter() + .position(|it| matches!(it, OutlineItem::File { path, .. } if path == "top.rs")) + .expect("top.rs survives, unaffected by src/'s fold"); + assert_eq!( + folded.visible_index[5], top_rs_visible_idx, + "top.rs (a sibling of src/, not nested under it) maps onto its own visible row" + ); + } + + #[test] + fn fold_outline_composes_build_items_and_apply_fold() { + let changesets = vec![cs( + "cs-a", + true, + false, + &[ + ("a1.txt", StagedStatus::None), + ("a2.txt", StagedStatus::None), + ], + )]; + let folded = fold_outline( + &changesets, + OutlineMode::Stack, + OutlineOrder::HeadFirst, + |key| { + *key == FoldKey::Header { + label: "cs-a".to_string(), + cs_idx: 0, + } + }, + ); + assert_eq!( + folded.items, + vec![OutlineItem::Header { + cs_idx: 0, + n: 1, + label: "cs-a".to_string(), + current: true, + needs_restack: false, + loading: false, + failed: false, + }], + "the header survives collapsed; both files are hidden" + ); + assert_eq!(folded.hidden_counts, vec![2]); + } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index caf4761..90da007 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -739,7 +739,10 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// [`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 /// root-level file); Tree/StackTree rows already carry the directory via ancestor Dir rows, so -/// `path` there is just the bare basename. The cursor row (the outline's OWN cursor — a separate coordinate space from the +/// `path` there is just the bare basename. A COLLAPSED [`OutlineItem::Header`]/[`OutlineItem::Dir`] +/// row (CS5, `outline-fold`) additionally carries a trailing dim ` ▸ N` (`N` = hidden FILE rows +/// 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 /// legible even after focus returns to the diff). `&mut App` (CS2, precedent: [`render_body`] @@ -750,7 +753,7 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { app.outline_height = area.height as usize; app.hit_regions.outline = Some(region_from(area)); - let items = app.outline_items(); + let (items, hidden_counts) = app.outline_items_with_hidden_counts(); // Bounds-clamp only — NOT a cursor-following derive: under the wheel's peek model a // scrolled-away viewport must survive the frame; cursor ops re-derive on their own. app.clamp_outline_scroll(items.len()); @@ -765,7 +768,8 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) // small (file trees, not file contents), so re-measuring the whole thing here is cheap. let max_line_width = items .iter() - .map(|item| build_outline_line(item, theme, icons).width()) + .zip(&hidden_counts) + .map(|(item, &hidden)| build_outline_line(item, theme, icons, hidden).width()) .max() .unwrap_or(0); app.clamp_outline_hscroll(max_line_width); @@ -778,8 +782,9 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) let Some(item) = items.get(item_idx) else { continue; }; + let hidden = hidden_counts.get(item_idx).copied().unwrap_or(0); let is_cursor = item_idx == cursor; - let line = build_outline_line(item, theme, icons); + 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) @@ -897,11 +902,32 @@ fn outline_status_spans( } } +/// CS5 (`outline-fold`): a collapsed Header/Dir row's trailing marker — dim ` ▸ N`, `N` being the +/// count of hidden FILE rows (not dirs) [`App::outline_items_with_hidden_counts`] attached to that +/// row. `None` for `hidden == 0` (an EXPANDED Header/Dir — or a File row, which never carries a +/// hidden count at all) — the locked "no chevron when expanded" rule reads a zero count as "don't +/// draw a marker" rather than "draw ` ▸ 0`". +fn fold_marker(hidden: usize, theme: &Palette) -> Option> { + (hidden > 0).then(|| { + TSpan::styled( + format!(" \u{25b8} {hidden}"), + Style::default().fg(theme.dim), + ) + }) +} + /// Build one outline row's rendered [`Line`] — see [`render_outline`]'s doc comment for the /// marker rules. `icons` (CS5, `workon.review.icons`) is [`IconMode::None`] by /// default, which reproduces the pre-CS5 row text exactly (no icon glyph, no extra space); only -/// [`IconMode::Nerd`] inserts an icon before the name/path. -fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> Line<'static> { +/// [`IconMode::Nerd`] inserts an icon before the name/path. `hidden` (CS5, `outline-fold`) is the +/// row's collapsed hidden-file count from [`App::outline_items_with_hidden_counts`] — `0` for +/// every row that isn't a collapsed Header/Dir; see [`fold_marker`]. +fn build_outline_line( + item: &OutlineItem, + theme: &Palette, + icons: IconMode, + hidden: usize, +) -> Line<'static> { match item { OutlineItem::Header { cs_idx, @@ -933,6 +959,7 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> L Style::default().fg(theme.dim), )); } + spans.extend(fold_marker(hidden, theme)); Line::from(spans) } OutlineItem::Dir { name, guides, .. } => { @@ -941,12 +968,14 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> L IconMode::None => String::new(), }; let text = format!("{}{icon}{name}/", tree_prefix(guides)); - Line::from(TSpan::styled( + let mut spans = vec![TSpan::styled( text, Style::default() .fg(theme.dim) .add_modifier(Modifier::ITALIC), - )) + )]; + spans.extend(fold_marker(hidden, theme)); + Line::from(spans) } OutlineItem::File { path, @@ -3846,6 +3875,115 @@ mod tests { ); } + // ── CS5 (`outline-fold`): collapse/expand marker ──────────────────────────────── + + #[test] + fn outline_collapsed_header_renders_a_trailing_dim_hidden_file_marker() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.set_outline_order(crate::outline::OutlineOrder::BaseFirst); + app.focus_outline(); + app.outline_top(); // cs-a's header row (BaseFirst: cs-a's header renders first) + app.outline_confirm(); // toggle fold — collapses cs-a, hiding its single file (a.txt) + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0 (the winbar) — it names the current file too (e.g. `[i/n] path`), which can + // false-positive a bare `contains` search, same gotcha `render_outline_file_row` already + // documents. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let (row_idx, header_row) = content + .iter() + .enumerate() + .find(|(_, r)| r.contains("Add a")) + .map(|(i, r)| (i, r.clone())) + .expect("cs-a's header row present"); + let y = row_idx as u16 + 1; // +1 to undo the y=0 skip above. + assert!( + header_row.contains("\u{25b8} 1"), + "collapsed header must show its 1 hidden file, got: {header_row:?}" + ); + assert!( + !content.iter().any(|r| r.contains("a.txt")), + "a.txt's row must be hidden while its header is collapsed, got:\n{}", + content.join("\n") + ); + + let row_chars: Vec = header_row.chars().collect(); + let marker_x = row_chars + .iter() + .position(|&c| c == '\u{25b8}') + .expect("marker glyph present") as u16; + assert_eq!( + buf.cell((marker_x, y)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected the collapsed marker to carry theme.dim, got: {header_row:?}" + ); + } + + #[test] + fn outline_expanded_header_renders_no_chevron_marker() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0 (the winbar) — see the gotcha noted above. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + assert!( + !content.iter().any(|r| r.contains('\u{25b8}')), + "no row should carry the collapsed marker while every Header/Dir is expanded, got:\n{}", + content.join("\n") + ); + } + + #[test] + fn outline_collapsed_dir_renders_a_trailing_dim_hidden_file_marker() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = changeset_with_nested_paths(&fixture); + if !app.outline_open() { + app.toggle_outline(); + } + app.outline_cycle_mode(); // Stack -> StackTree + app.outline_cycle_mode(); // StackTree -> Flat + app.outline_cycle_mode(); // Flat -> Tree + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); + + app.focus_outline(); + app.outline_top(); // src/ (dirs-before-files root ordering — see the tree-guide test above) + app.outline_confirm(); // toggle fold — collapses src/, hiding its one file (a.txt) + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0 (the winbar) — its OWN current-file label can itself contain `src/` (e.g. + // `[1/1] src/a.txt`) and false-positive the `contains("src/")` search below if included. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let dir_row = content + .iter() + .find(|r| r.contains("src/")) + .expect("src/ row present"); + assert!( + dir_row.contains("\u{25b8} 1"), + "collapsed src/ must show its 1 hidden file, got: {dir_row:?}" + ); + assert!( + !content.iter().any(|r| r.contains("a.txt")), + "a.txt must be hidden under collapsed src/, got:\n{}", + content.join("\n") + ); + assert!( + content.iter().any(|r| r.contains("top.txt")), + "top.txt (a sibling, not nested under src/) must remain visible, got:\n{}", + content.join("\n") + ); + } + // ── CS2 (outline-row-shape): smart path render ───────────────────────────────── #[test] diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 8ebe260..2623a53 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -2418,15 +2418,26 @@ mod tests { .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); - // CS3: pin BaseFirst explicitly — this test exercises Enter's header-jump + focus - // return, which is orthogonal to display order, but the `-3` row offset below assumes - // the base->head row layout. + // CS3: pin BaseFirst explicitly — this test exercises Enter's File-row jump + focus + // return, which is orthogonal to display order, but the row offset below assumes the + // base->head row layout. app.set_outline_order(workon_review::outline::OutlineOrder::BaseFirst); app.toggle_outline(); // close app.toggle_outline(); // open + focus, cursor synced onto cs-b's file row assert!(app.outline_focused()); - // Move the outline cursor up onto cs-a's header row. - app.outline_move_by(-3); + // Move the outline cursor onto cs-a's FILE row (rows, BaseFirst: [Header a, File a.txt, + // Header b, File b.txt] — cursor starts at 3; -2 lands on File a.txt at row 1). CS5 + // (`outline-fold`) removed Enter's old header-jump behavior — see + // `enter_on_a_header_row_toggles_fold_and_keeps_focus` below for that case — so this + // keybinding-dispatch test needs a File row to still exercise a real jump+unfocus. + app.outline_move_by(-2); + assert_eq!( + app.current_cs(), + 0, + "sanity: the move itself already landed on cs-a's file (moving onto a File row \ + always jumps, per `outline_move_by`'s own contract) — Enter below re-confirms the \ + same jump through the real keybinding-dispatch path" + ); let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); @@ -2440,12 +2451,63 @@ mod tests { assert_eq!( app.current_cs(), 0, - "Enter on cs-a's header must jump there" + "Enter on a File row must (still) land on cs-a" ); - assert_eq!(app.current, 0, "...landing on its first file"); + assert_eq!(app.current, 0, "...landing on its file"); assert!( !app.outline_focused(), - "Enter returns focus to the diff after jumping" + "Enter on a File row returns focus to the diff" + ); + } + + #[test] + fn enter_on_a_header_row_toggles_fold_and_keeps_focus() { + // CS5 (`outline-fold`): Enter on a Header/Dir row no longer jumps+unfocuses — it toggles + // that row's fold and deliberately keeps focus. This is the header-row counterpart to + // `enter_confirms_an_outline_jump_and_returns_focus_to_the_diff` above, verified through + // the same real keybinding-dispatch path (`update`/`map_key`), not a direct + // `App::outline_confirm()` call. + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.set_outline_order(workon_review::outline::OutlineOrder::BaseFirst); + app.toggle_outline(); // close + app.toggle_outline(); // open + focus, cursor synced onto cs-b's file row + // Move the outline cursor onto cs-a's header row (rows, BaseFirst: [Header a, File a.txt, + // Header b, File b.txt] — cursor starts at 3; -3 lands on Header a at row 0, which never + // jumps). + app.outline_move_by(-3); + let before_cs = app.current_cs(); + let before_file = app.current; + let rows_before = app.outline_items().len(); + + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Enter)), + ); + + assert_eq!( + app.current_cs(), + before_cs, + "Enter on a header must NOT jump the diff (CS5)" + ); + assert_eq!(app.current, before_file); + assert!( + app.outline_focused(), + "Enter on a header toggles its fold and keeps focus (CS5), rather than confirming a \ + jump" + ); + assert!( + app.outline_items().len() < rows_before, + "cs-a's file row must now be hidden under its collapsed header" ); } From 9e34d06d1841599a81f1d6b27636056d3641a177 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 12:34:42 -0400 Subject: [PATCH 137/203] fix(review): outline header label, indent, and marker polish --- git-workon-review/src/app.rs | 110 ++++++++++++++++++++++++++++--- git-workon-review/src/outline.rs | 20 +++--- git-workon-review/src/render.rs | 33 +++++----- git-workon-review/src/summary.rs | 5 +- 4 files changed, 131 insertions(+), 37 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 45eaaec..fc32ad9 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1339,6 +1339,20 @@ pub struct Notice { pub severity: Severity, } +/// The one display-label rule for a changeset, shared by the outline header +/// ([`App::outline_snapshot`]), the summary panel ([`App::summary_for`]), and the winbar +/// (`render::render_winbar`): title, falling back to name — except the synthetic uncommitted +/// worktree layer, which is named after the SAME branch as its committed node (see +/// `workon::Changeset`'s `insert_uncommitted_layer` / [`crate::acquire::uncommitted_changeset`]) +/// and so renders as "Uncommitted changes" instead of duplicating that label. +pub(crate) fn display_label(cs: &Changeset) -> String { + if cs.span == ChangesetSpan::Uncommitted { + "Uncommitted changes".to_string() + } else { + cs.title.clone().unwrap_or_else(|| cs.name.clone()) + } +} + impl App { /// Build an [`App`] reviewing a single uncommitted changeset — the M2–M4 shape, and still /// what a non-Graphite (or clean-Graphite-tip) repo degrades to under M5's auto-detect @@ -2473,7 +2487,7 @@ impl App { self.changesets .iter() .map(|v| OutlineChangeset { - label: v.cs.title.clone().unwrap_or_else(|| v.cs.name.clone()), + label: display_label(&v.cs), current: v.cs.current, needs_restack: v.cs.needs_restack, loading: v.is_pending(), @@ -2571,11 +2585,7 @@ impl App { match target { SummaryTarget::Changeset(cs_idx) => { let view = &self.changesets[cs_idx]; - let label = view - .cs - .title - .clone() - .unwrap_or_else(|| view.cs.name.clone()); + let label = display_label(&view.cs); let failure_message = view.failure_message().map(|s| s.to_string()); Summary::Changeset(summary::changeset_summary( label, @@ -8788,11 +8798,15 @@ mod tests { /// A minimal [`Changeset`] descriptor for the slot tests below — the slot model only cares /// about the metadata `ChangesetView::pending`/`failed` carry alongside a diff-free - /// [`DiffState`], not any real git content. + /// [`DiffState`], not any real git content. The span must be a committed variant (zero OID + /// is fine, nothing diffs it) so the outline labels these by name rather than as the + /// "Uncommitted changes" layer. fn bare_changeset(name: &str, current: bool) -> Changeset { Changeset { name: name.to_string(), - span: ChangesetSpan::Uncommitted, + span: ChangesetSpan::CommittedRoot { + head: git2::Oid::ZERO_SHA1, + }, title: None, current, needs_restack: false, @@ -9123,6 +9137,86 @@ mod tests { assert_eq!(change_for("u1.txt"), FileStatus::Untracked); } + /// `outline_snapshot`'s label fallback (`title` else `name`) used to render the SAME label + /// for a branch's committed node and its own uncommitted worktree layer — both are named + /// after the same branch, no title on either (see `FoldKey`'s doc comment, outline.rs). The + /// uncommitted layer must instead say "Uncommitted changes", so the branch name appears + /// exactly once (on the committed node). + #[test] + fn outline_snapshot_labels_the_uncommitted_layer_uncommitted_changes_not_the_branch_name() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("base.txt", "b\n") + .create("base") + .unwrap(); + let head = fixture + .commit("main") + .file("c1.txt", "c1\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + std::fs::write(repo.workdir().unwrap().join("u1.txt"), "u1\n").unwrap(); + + let committed = Changeset { + name: "feature".to_string(), + span: ChangesetSpan::Committed { base, head }, + title: None, + current: false, + needs_restack: false, + }; + let uncommitted = Changeset { + name: "feature".to_string(), + span: ChangesetSpan::Uncommitted, + title: None, + current: true, + needs_restack: false, + }; + let view_c = ChangesetView::from_changeset_diff( + committed.clone(), + crate::acquire::diff_changeset(repo, &committed).unwrap(), + ); + let view_u = ChangesetView::from_changeset_diff( + uncommitted.clone(), + crate::acquire::diff_changeset(repo, &uncommitted).unwrap(), + ); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view_c, view_u]); + app.open_current(); + app.outline.mode = OutlineMode::Stack; + // Pin BaseFirst: this asserts an exact label vec, and display order is incidental here. + app.outline.order = OutlineOrder::BaseFirst; + + let labels: Vec = app + .outline_items() + .into_iter() + .filter_map(|it| match it { + OutlineItem::Header { label, .. } => Some(label), + _ => None, + }) + .collect(); + assert_eq!( + labels, + vec!["feature", "Uncommitted changes"], + "the committed node keeps the branch name; the uncommitted layer must say \ + \"Uncommitted changes\" instead of duplicating it" + ); + + // Label parity: the summary panel (and the winbar, which reads the same + // `display_label` helper) must agree with the outline header — the uncommitted layer + // is `current: true` in this fixture, so both non-outline surfaces target it. + let Summary::Changeset(summary) = app.summary_for(SummaryTarget::Changeset(1)) else { + panic!("expected a changeset summary for the uncommitted layer"); + }; + assert_eq!( + summary.label, "Uncommitted changes", + "the summary panel must use the same display-label rule as the outline header" + ); + } + #[test] fn outline_move_by_on_a_file_row_jumps_the_diff() { let mut app = two_committed_changesets_two_and_one_files(); diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 1393f8b..b8de3c7 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -157,8 +157,9 @@ pub struct OutlineFile { /// to know about [`crate::app::ChangesetView`] or `workon::Changeset` at all. #[derive(Debug, Clone)] pub struct OutlineChangeset { - /// The changeset's title, falling back to its name — same rule the winbar (render.rs) - /// already uses. + /// The changeset's display label (`crate::app::display_label` — title falling back to name, + /// with the uncommitted layer rendered as "Uncommitted changes"), the same rule the winbar + /// and summary panel use. pub label: String, /// Mirrors `workon::Changeset::current` — drives the outline's green current marker. pub current: bool, @@ -284,15 +285,12 @@ pub(crate) fn build_items( /// single trie has no one owning changeset to key by). /// /// `cs_idx` is load-bearing here, not just belt-and-suspenders: a changeset's own `label` is NOT -/// unique across a single snapshot — [`crate::acquire::uncommitted_changeset`] and Graphite's -/// `insert_uncommitted_layer` both name the synthetic uncommitted-layer changeset after the SAME -/// branch its committed node is named after (no title on either), so a branch's committed node -/// and its own uncommitted worktree layer are two DIFFERENT rows that render the identical label. -/// Keying by label alone would fold both together the moment either was toggled. `cs_idx` is -/// still the true index into `App::changesets` (stable across an ordinary refresh — only a -/// structural stack change, e.g. a changeset added/removed, shifts it), matching the same -/// "identity survives refresh via `cs_idx`" precedent [`crate::app::OutlineRowIdentity`] already -/// relies on for staging-verb restore. +/// guaranteed unique across a single snapshot in general (e.g. two changesets could otherwise +/// share a title), so keying by label alone would risk folding unrelated rows together the +/// moment either was toggled. `cs_idx` is still the true index into `App::changesets` (stable +/// across an ordinary refresh — only a structural stack change, e.g. a changeset added/removed, +/// shifts it), matching the same "identity survives refresh via `cs_idx`" precedent +/// [`crate::app::OutlineRowIdentity`] already relies on for staging-verb restore. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum FoldKey { Header { label: String, cs_idx: usize }, diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 90da007..ce1a379 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -42,8 +42,8 @@ use crate::wordiff::Span as WordSpan; // auto-detection ever picks Nerd for the user). Picked from the classic BMP nerd-font sets // (`fa`/`oct`) rather than devicons' broader (partly supplementary-plane) table, for wider // font compatibility — see `icons.rs`'s v3 doc note. -/// Nerd-mode "this is the current changeset" marker, replacing the plain `●` (U+25CF). -const NERD_CURRENT_MARKER: char = '\u{f111}'; // nf-fa-circle +/// Nerd-mode "this is the current changeset" marker, replacing the plain `•` (U+2022). +const NERD_CURRENT_MARKER: char = '\u{f444}'; // nf-oct-dot-fill /// Nerd-mode needs-restack marker, replacing the plain `⚠` (U+26A0). const NERD_WARN_MARKER: char = '\u{f071}'; // nf-fa-warning /// Nerd-mode failed-changeset marker, replacing the plain `✗` (U+2717). @@ -66,7 +66,7 @@ const NERD_DIFF_REMOVED: char = '\u{f458}'; // nf-oct-diff-removed fn current_marker(icons: IconMode) -> char { match icons { IconMode::Nerd => NERD_CURRENT_MARKER, - IconMode::None => '\u{25CF}', + IconMode::None => '\u{2022}', } } @@ -123,12 +123,13 @@ fn changeset_title_spans( icons: IconMode, counter: Option<(usize, usize)>, ) -> Vec> { - let marker = if current { - format!("{} ", current_marker(icons)) - } else { - " ".to_string() - }; - let mut spans = vec![TSpan::styled(marker, Style::default().fg(theme.current_fg))]; + let mut spans = Vec::new(); + if current { + spans.push(TSpan::styled( + format!("{} ", current_marker(icons)), + Style::default().fg(theme.current_fg), + )); + } if icons == IconMode::Nerd { spans.push(TSpan::styled( format!("{NERD_BRANCH_ICON} "), @@ -731,7 +732,7 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect } /// 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 +/// 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 @@ -1122,7 +1123,7 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { let cs = app.current_changeset(); let i = app.current_cs() + 1; let n = app.changeset_count(); - let title = cs.title.as_deref().unwrap_or(cs.name.as_str()); + let title = crate::app::display_label(cs); let icons = app.icon_mode(); let mut spans = vec![TSpan::styled( @@ -3478,9 +3479,9 @@ mod tests { let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); let row = content .iter() - .position(|r| r.contains('\u{25CF}')) + .position(|r| r.contains('\u{2022}')) .expect("current marker present in the outline"); - let marker_x = content[row].find('\u{25CF}').unwrap() as u16; + let marker_x = content[row].find('\u{2022}').unwrap() as u16; assert_eq!( buf.cell((marker_x, row as u16)).unwrap().style().fg, Some(Palette::dark().current_fg), @@ -3562,7 +3563,7 @@ mod tests { .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 + // 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(); @@ -3620,7 +3621,7 @@ mod tests { .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 + // `•` 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(); @@ -4641,7 +4642,7 @@ mod tests { "expected the nerd needs-restack marker, got:\n{joined}" ); assert!( - !joined.contains('\u{25CF}') && !joined.contains('\u{26A0}'), + !joined.contains('\u{2022}') && !joined.contains('\u{26A0}'), "nerd mode must not leave the plain unicode markers behind in the outline pane, got:\n{joined}" ); assert!( diff --git a/git-workon-review/src/summary.rs b/git-workon-review/src/summary.rs index 9e073c8..a34f4cd 100644 --- a/git-workon-review/src/summary.rs +++ b/git-workon-review/src/summary.rs @@ -9,8 +9,9 @@ //! //! `workon::Changeset` (see `git-workon-lib/src/changeset.rs`) exposes only `name`/`title` for a //! changeset today — no commit body/message. [`changeset_summary`] therefore renders the -//! label (title, falling back to name — the same rule the winbar/outline header already use) -//! plus the diffstat; there is no commit-message row. Surfacing the commit body would need a +//! label (`crate::app::display_label` — title falling back to name, with the uncommitted layer +//! rendered as "Uncommitted changes"; the same rule the winbar/outline header use) plus the +//! diffstat; there is no commit-message row. Surfacing the commit body would need a //! `repo.find_commit` lookup keyed off the changeset's head OID — left as a follow-up, not part //! of this changeset's scope. From 4e6c826deeeb22496805aab407ba8769d7f756ee Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 12:58:05 -0400 Subject: [PATCH 138/203] feat(review): outline changeset nav and fold-all keybindings --- git-workon-review/src/app.rs | 261 ++++++++++++++++++++++++++++++++ git-workon-review/src/keymap.rs | 93 +++++++++++- git-workon-review/src/tui.rs | 12 ++ 3 files changed, 364 insertions(+), 2 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index fc32ad9..8a6ca6d 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -3036,6 +3036,37 @@ impl App { self.outline_move_to(last); } + /// `n` while the outline has focus: jump the cursor to the next [`OutlineItem::Header`] row + /// AFTER the current cursor position, or no-op (no wraparound) when there isn't one. Goes + /// through [`Self::outline_move_to`], so — like `g`/`G` — landing on a Header row never jumps + /// the diff (only a Header's own `Enter`/fold toggle or a File-row nav does that). + pub fn outline_next_changeset(&mut self) { + let items = self.outline_items(); + let cursor = self.outline.cursor; + if let Some(off) = items + .iter() + .skip(cursor + 1) + .position(|item| matches!(item, OutlineItem::Header { .. })) + { + self.outline_move_to(cursor + 1 + off); + } + } + + /// `p` while the outline has focus: jump the cursor to the next [`OutlineItem::Header`] row + /// BEFORE the current cursor position, or no-op (no wraparound) when there isn't one. The + /// counterpart to [`Self::outline_next_changeset`] — see its doc comment for the shared + /// no-diff-jump invariant. + pub fn outline_prev_changeset(&mut self) { + let items = self.outline_items(); + let cursor = self.outline.cursor; + if let Some(idx) = items[..cursor] + .iter() + .rposition(|item| matches!(item, OutlineItem::Header { .. })) + { + self.outline_move_to(idx); + } + } + /// `Enter` while the outline has focus: a FILE row jumps the diff straight there and returns /// focus to the diff (unchanged since CS3). A HEADER or DIR row instead TOGGLES that row's /// fold state (CS5, `outline-fold`) and deliberately does NOT return focus — you're @@ -3082,6 +3113,41 @@ impl App { self.derive_outline_scroll(self.outline_items().len()); } + /// `zM` while the outline has focus: collapse every foldable (Header/Dir) row of the CURRENT + /// [`OutlineMode`], unlike [`Self::outline_toggle_fold`]'s single-row flip. Scans the + /// UNFOLDED build ([`outline::build_items`] over the current snapshot — the same source + /// [`Self::outline_folded`] itself folds) rather than [`Self::outline_items`], so a row + /// already hidden under an existing fold still gets its own key recorded (collapsing + /// everything must be idempotent regardless of what's already collapsed). Unlike + /// [`Self::outline_toggle_fold`], this can hide the row the cursor itself sits on, so it + /// re-derives the cursor via [`Self::sync_outline_to_current`] (the same reseat + /// [`Self::outline_cycle_mode`] uses for its own row-list reshape) rather than trusting the + /// toggle's "only descendants move" invariant, which doesn't hold here. + pub fn outline_collapse_all(&mut self) { + let snapshot = self.outline_snapshot(); + let full = outline::build_items(&snapshot, self.outline.mode, self.outline.order); + let set = self.outline.folds.entry(self.outline.mode).or_default(); + for item in &full { + if let Some(key) = FoldKey::for_item(item) { + set.insert(key); + } + } + self.sync_outline_to_current(); + } + + /// `zR` while the outline has focus: expand every folded row of the CURRENT [`OutlineMode`] — + /// clears that mode's fold set entirely. See [`Self::outline_collapse_all`] for the cursor + /// reseat rationale (shared here too, even though expanding can only ever ADD rows, never + /// hide the cursor's own). + pub fn outline_expand_all(&mut self) { + self.outline + .folds + .entry(self.outline.mode) + .or_default() + .clear(); + self.sync_outline_to_current(); + } + // ── Outline staging (CS7) ─────────────────────────────────────────────────── /// Whether the changeset at `cs_idx` is a committed range rather than the uncommitted @@ -10554,6 +10620,201 @@ mod tests { ); } + // ── n/p (outline changeset nav) + zM/zR (collapse/expand all) ────────────── + + #[test] + fn outline_next_changeset_jumps_to_the_next_header_without_jumping_the_diff() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + // Row order (BaseFirst): [Header cs-a, File a1, File a2, Header cs-b, File b1]. + app.outline.cursor = 1; // a1's row + let cursor_before = (app.current_cs(), app.current); + + app.outline_next_changeset(); + assert_eq!(app.outline.cursor, 3, "must land on cs-b's header row"); + assert_eq!( + (app.current_cs(), app.current), + cursor_before, + "a header landing must not jump the diff" + ); + } + + #[test] + fn outline_next_changeset_does_not_wrap_past_the_last_header() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + app.outline.cursor = 3; // cs-b's header, the LAST header row + + app.outline_next_changeset(); + assert_eq!( + app.outline.cursor, 3, + "no next header to jump to — the cursor must not move" + ); + } + + #[test] + fn outline_prev_changeset_jumps_to_the_previous_header_without_jumping_the_diff() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + app.outline.cursor = 4; // b1's row + let cursor_before = (app.current_cs(), app.current); + + app.outline_prev_changeset(); + assert_eq!(app.outline.cursor, 3, "must land on cs-b's own header row"); + assert_eq!( + (app.current_cs(), app.current), + cursor_before, + "a header landing must not jump the diff" + ); + + app.outline_prev_changeset(); + assert_eq!(app.outline.cursor, 0, "must land on cs-a's header row"); + } + + #[test] + fn outline_prev_changeset_does_not_wrap_past_the_first_header() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + app.outline.cursor = 0; // cs-a's header, the FIRST header row + + app.outline_prev_changeset(); + assert_eq!( + app.outline.cursor, 0, + "no previous header to jump to — the cursor must not move" + ); + } + + #[test] + fn outline_collapse_all_folds_every_header_leaving_only_header_rows() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + assert_eq!(app.outline_items().len(), 5, "sanity: both stacks expanded"); + + app.outline_collapse_all(); + let items = app.outline_items(); + assert_eq!( + items.len(), + 2, + "only the two Header rows remain once every changeset is collapsed" + ); + assert!( + items + .iter() + .all(|it| matches!(it, OutlineItem::Header { .. })), + "every remaining row must be a Header row: {items:?}" + ); + } + + #[test] + fn outline_collapse_all_is_idempotent_when_a_header_is_already_folded() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + app.outline.cursor = 3; // cs-b's header + app.outline_confirm(); // pre-collapse cs-b only + + app.outline_collapse_all(); + assert_eq!( + app.outline_items().len(), + 2, + "collapse-all must still fold cs-a even though cs-b was already folded" + ); + } + + #[test] + fn outline_collapse_all_reseats_a_cursor_on_a_row_that_just_got_hidden() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + app.outline.cursor = 1; // a1's row — about to be hidden under cs-a's header + + app.outline_collapse_all(); + let items = app.outline_items(); + assert!( + app.outline.cursor < items.len(), + "the cursor must land inside the shrunk row list, not stay at a now-invalid index" + ); + assert!( + matches!( + items[app.outline.cursor], + OutlineItem::Header { cs_idx: 0, .. } + ), + "the cursor must reseat onto cs-a's collapsed header, the ancestor of the hidden row \ + it was on" + ); + } + + #[test] + fn outline_expand_all_restores_every_row() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + let rows_before = app.outline_items().len(); + + app.outline_collapse_all(); + assert!(app.outline_items().len() < rows_before); + + app.outline_expand_all(); + assert_eq!( + app.outline_items().len(), + rows_before, + "expand-all must restore every row collapse-all hid" + ); + assert!( + app.outline + .folds + .get(&OutlineMode::Stack) + .is_none_or(|s| s.is_empty()), + "expand-all must clear the CURRENT mode's fold set" + ); + } + + #[test] + fn outline_collapse_all_and_expand_all_are_scoped_to_the_current_mode() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + + app.outline_collapse_all(); + assert!(app + .outline + .folds + .get(&OutlineMode::Stack) + .is_some_and(|s| !s.is_empty())); + + app.outline.mode = OutlineMode::StackTree; + assert!( + app.outline + .folds + .get(&OutlineMode::StackTree) + .is_none_or(|s| s.is_empty()), + "Stack's collapse-all must not leak into StackTree's own fold set" + ); + } + #[test] fn outline_stage_targets_the_correct_row_when_an_unrelated_header_is_folded() { // The highest-risk CS5 interaction: folding one changeset's header shifts every LATER diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 7c5efaa..fd61def 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -81,6 +81,10 @@ pub enum Command { OutlineDiscard, OutlineHscrollLeft, OutlineHscrollRight, + OutlineNextChangeset, + OutlinePrevChangeset, + OutlineCollapseAll, + OutlineExpandAll, } /// One row of the action registry: a [`Command`] with its stable config identity (`view` + @@ -387,6 +391,34 @@ pub static REGISTRY: &[Registered] = &[ default_keys: ">", description: "Pan the outline right", }, + Registered { + command: Command::OutlineNextChangeset, + view: View::Outline, + name: "outline-next-changeset", + default_keys: "n", + description: "Jump to the next changeset", + }, + Registered { + command: Command::OutlinePrevChangeset, + view: View::Outline, + name: "outline-prev-changeset", + default_keys: "p", + description: "Jump to the previous changeset", + }, + Registered { + command: Command::OutlineCollapseAll, + view: View::Outline, + name: "outline-collapse-all", + default_keys: "zM", + description: "Collapse every changeset/directory in the outline", + }, + Registered { + command: Command::OutlineExpandAll, + view: View::Outline, + name: "outline-expand-all", + default_keys: "zR", + description: "Expand every changeset/directory in the outline", + }, ]; /// One matchable key press: a [`KeyCode`] plus whether Ctrl/Alt are required. **Shift is @@ -847,10 +879,15 @@ const DIFF_HINTS: &[HintItem] = &[ const OUTLINE_HINTS: &[HintItem] = &[ HintItem::Pair(Command::OutlineDown, Command::OutlineUp, "move"), HintItem::One(Command::OutlineConfirm, "open"), + HintItem::Pair( + Command::OutlineNextChangeset, + Command::OutlinePrevChangeset, + "changeset", + ), HintItem::One(Command::OutlineCycleMode, "mode"), - HintItem::One(Command::ToggleOutline, "outline"), + // No `o outline` / `q quit` here: with the changeset pair the full set no longer fits 80 + // cols, and both stay discoverable in the diff footer and the help overlay. HintItem::One(Command::ToggleHelp, "help"), - HintItem::One(Command::Quit, "quit"), ]; /// Build the persistent, always-visible footer hint string for `focused` ([`View::Diff`] or @@ -1089,6 +1126,50 @@ mod tests { ); } + #[test] + fn n_and_p_dispatch_outline_changeset_nav_with_no_collisions() { + let km = Keymap::defaults(); + assert!( + km.warnings().is_empty(), + "n/p defaults must not collide with anything: {:?}", + km.warnings() + ); + assert_eq!( + feed(&km, true, &[key(KeyCode::Char('n'))]), + Dispatch::Command(Command::OutlineNextChangeset) + ); + assert_eq!( + feed(&km, true, &[key(KeyCode::Char('p'))]), + Dispatch::Command(Command::OutlinePrevChangeset) + ); + } + + #[test] + fn z_m_and_z_r_dispatch_outline_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, + true, + &[key(KeyCode::Char('z')), key(KeyCode::Char('M'))] + ), + Dispatch::Command(Command::OutlineCollapseAll) + ); + assert_eq!( + feed( + &km, + true, + &[key(KeyCode::Char('z')), key(KeyCode::Char('R'))] + ), + Dispatch::Command(Command::OutlineExpandAll) + ); + } + #[test] fn a_config_rebind_overrides_the_default() { let km = Keymap::from_bindings(&[RawBinding { @@ -1314,10 +1395,18 @@ mod tests { let hint = footer_hint(&km, View::Outline, OutlineMode::Stack); assert!(hint.contains("j/k move"), "got: {hint:?}"); assert!(hint.contains("enter open"), "got: {hint:?}"); + assert!(hint.contains("n/p changeset"), "got: {hint:?}"); assert!( hint.contains("i \u{2192}stack-tree"), "cycling from Stack must show the next mode, StackTree; got: {hint:?}" ); + assert!(hint.contains("? help"), "got: {hint:?}"); + assert!( + hint.chars().count() <= 80, + "the curated outline hint must fit an 80-col footer even with the longest \ + next-mode label (stack-tree); got {} chars: {hint:?}", + hint.chars().count() + ); } #[test] diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 2623a53..f1dac88 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -461,6 +461,10 @@ enum Action { OutlineDiscard, OutlineHscrollLeft, OutlineHscrollRight, + OutlineNextChangeset, + OutlinePrevChangeset, + OutlineCollapseAll, + OutlineExpandAll, None, } @@ -511,6 +515,10 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::OutlineDiscard => Action::OutlineDiscard, Command::OutlineHscrollLeft => Action::OutlineHscrollLeft, Command::OutlineHscrollRight => Action::OutlineHscrollRight, + Command::OutlineNextChangeset => Action::OutlineNextChangeset, + Command::OutlinePrevChangeset => Action::OutlinePrevChangeset, + Command::OutlineCollapseAll => Action::OutlineCollapseAll, + Command::OutlineExpandAll => Action::OutlineExpandAll, } } @@ -648,6 +656,10 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::OutlineDiscard => app.outline_discard(), Action::OutlineHscrollLeft => app.outline_hscroll_left(), Action::OutlineHscrollRight => app.outline_hscroll_right(), + Action::OutlineNextChangeset => app.outline_next_changeset(), + Action::OutlinePrevChangeset => app.outline_prev_changeset(), + Action::OutlineCollapseAll => app.outline_collapse_all(), + Action::OutlineExpandAll => app.outline_expand_all(), Action::None => {} } false From f3b99eabc6c4067898a95aea9def6e73e2431818 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 17 Jul 2026 18:30:58 -0400 Subject: [PATCH 139/203] fix(review): key changeset identity by name plus span kind --- git-workon-review/src/app.rs | 156 +++++++++++++++++++++++++++++++---- 1 file changed, 140 insertions(+), 16 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 8a6ca6d..af18847 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -786,6 +786,35 @@ impl OutlineRowIdentity { } } +/// What "the same changeset" means once a refresh has re-resolved the world: branch name plus +/// span KIND. Name alone is ambiguous — [`workon::assemble_changesets`]'s uncommitted layer is +/// named after the current branch, so that branch's committed node and the uncommitted layer +/// share a name, and a name-only re-find silently lands on the committed node (the "staging +/// teleports the diff viewer" / "discard does nothing" dogfood bugs). Deliberately NOT the full +/// [`workon::ChangesetSpan`]: a staging op rewrites the index, and a future stack op rewrites +/// base/head OIDs, yet the result is still "the same changeset" to the reviewer — identity must +/// survive exactly the operations that change the span's contents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangesetIdentity { + name: String, + uncommitted: bool, +} + +impl ChangesetIdentity { + /// Capture `cs`'s identity ahead of an operation that rebuilds [`App::changesets`]. + fn of(cs: &Changeset) -> Self { + Self { + name: cs.name.clone(), + uncommitted: cs.span == ChangesetSpan::Uncommitted, + } + } + + /// Whether `cs` is the changeset this identity was captured from, across a rebuild. + fn matches(&self, cs: &Changeset) -> bool { + cs.name == self.name && (cs.span == ChangesetSpan::Uncommitted) == self.uncommitted + } +} + /// The outline side pane's own state (locked fork 3): whether it's showing, whether IT (rather /// than the diff) currently has keyboard focus, its own cursor (an index into /// [`App::outline_items`]'s row list — a wholly separate coordinate space from [`App::cursor`]), @@ -1300,17 +1329,18 @@ pub enum PendingOp { file_idx: usize, selections: Vec<(usize, LineSelection)>, }, - /// CS7: discard every file in `files` — `(changeset name, file path)` pairs — from the + /// CS7: discard every file in `files` — `(changeset identity, file path)` pairs — from the /// worktree: an outline File row's single target, or a Dir row's every file under its path. - /// Stored by NAME + PATH rather than raw `(cs_idx, file_idx)` indices because the confirm - /// modal doesn't stop the tick beat: an external index change (e.g. `git add` from another - /// terminal) can run a full refresh between `d` and `y`, rebuilding the per-changeset file - /// lists and shifting positions — [`App::resolve_confirm`] re-resolves each pair against the - /// LIVE changesets at answer time (silently skipping any that vanished) so a stale index can - /// never discard the wrong file. `identity` is the acted-on outline row's - /// [`OutlineRowIdentity`], captured at request-time for the post-op outline cursor restore. + /// Stored by [`ChangesetIdentity`] + PATH rather than raw `(cs_idx, file_idx)` indices + /// because the confirm modal doesn't stop the tick beat: an external index change (e.g. + /// `git add` from another terminal) can run a full refresh between `d` and `y`, rebuilding + /// the per-changeset file lists and shifting positions — [`App::resolve_confirm`] + /// re-resolves each pair against the LIVE changesets at answer time (silently skipping any + /// that vanished) so a stale index can never discard the wrong file. `identity` is the + /// acted-on outline row's [`OutlineRowIdentity`], captured at request-time for the post-op + /// outline cursor restore. DiscardOutlineFiles { - files: Vec<(String, String)>, + files: Vec<(ChangesetIdentity, String)>, identity: OutlineRowIdentity, }, } @@ -1686,7 +1716,7 @@ impl App { return; } - let prev_cs_name = self.cur().cs.name.clone(); + let prev_cs_id = ChangesetIdentity::of(&self.cur().cs); let current_path = self .cur() .diff @@ -1733,7 +1763,7 @@ impl App { self.current_cs = new_views .iter() - .position(|v| v.cs.name == prev_cs_name) + .position(|v| prev_cs_id.matches(&v.cs)) .unwrap_or_else(|| current_cs_index(&new_views)); self.base_label = base_label_for(&new_views[self.current_cs].cs); self.changesets = new_views; @@ -3323,12 +3353,12 @@ impl App { targets.len() ), }; - let files: Vec<(String, String)> = targets + let files: Vec<(ChangesetIdentity, String)> = targets .iter() .filter_map(|&(cs_idx, file_idx)| { let view = self.changesets.get(cs_idx)?; let path = view.files().get(file_idx)?.path.clone(); - Some((view.cs.name.clone(), path)) + Some((ChangesetIdentity::of(&view.cs), path)) }) .collect(); self.request_confirm(prompt, PendingOp::DiscardOutlineFiles { files, identity }); @@ -4120,14 +4150,14 @@ impl App { self.run_op(LineSelectionOp::new(file, selections, StageVerb::Discard)); } PendingOp::DiscardOutlineFiles { files, identity } => { - // Re-resolve each (changeset name, path) pair against the LIVE changesets — an + // Re-resolve each (changeset identity, path) pair against the LIVE changesets — an // intervening tick refresh may have shifted every index since `d` was pressed // (see the variant's doc); a pair that no longer resolves is silently skipped // (its file already left the diff, so there's nothing left to discard). let ops: Vec> = files .iter() - .filter_map(|(cs_name, path)| { - let view = self.changesets.iter().find(|v| v.cs.name == *cs_name)?; + .filter_map(|(cs_id, path)| { + let view = self.changesets.iter().find(|v| cs_id.matches(&v.cs))?; let file = view.files().iter().find(|f| f.path == *path)?.clone(); Some(Box::new(FileStagingOp::file(file, StageVerb::Discard)) as Box) @@ -10387,6 +10417,100 @@ mod tests { )); } + /// A Graphite stack whose current branch `b` has BOTH a committed changeset and the + /// uncommitted layer — [`workon::assemble_changesets`]'s `insert_uncommitted_layer` names + /// the layer after the current branch, so two changesets share the name "b". Built through + /// the production resolve path ([`crate::acquire::resolve_changesets`]) so `App::refresh` + /// re-resolves the same shape. + fn graphite_stack_app_on_uncommitted_layer() -> (Fixture, App) { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .branch_metadata("b", "a") + .untracked_file("scratch.txt", "hi\n") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + repo.set_head("refs/heads/b").unwrap(); + repo.checkout_head(None).unwrap(); + + let changesets = crate::acquire::resolve_changesets(repo, "b").expect("resolve"); + assert!( + changesets + .iter() + .any(|cs| cs.name == "b" && cs.span != ChangesetSpan::Uncommitted), + "precondition: a committed changeset named after the current branch" + ); + assert!( + changesets + .iter() + .any(|cs| cs.name == "b" && cs.span == ChangesetSpan::Uncommitted), + "precondition: the uncommitted layer shares that name" + ); + let mut views = Vec::with_capacity(changesets.len()); + for cs in changesets { + let diff = crate::acquire::diff_changeset(repo, &cs).unwrap(); + views.push(ChangesetView::from_changeset_diff(cs, diff)); + } + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, views); + app.open_current(); + assert_eq!( + app.cur().cs.span, + ChangesetSpan::Uncommitted, + "precondition: the review opens on the uncommitted layer" + ); + (fixture, app) + } + + /// Refresh re-finds the current changeset by NAME alone — with the uncommitted layer named + /// after its branch, the first name match is the committed "b" changeset, and the reviewer + /// is silently teleported off the uncommitted layer. Every staging op refreshes, so this is + /// the "stage a file and the diff viewer jumps to another changeset" dogfood bug. + #[test] + fn refresh_stays_on_the_uncommitted_layer_despite_a_same_named_committed_changeset() { + let (_fixture, mut app) = graphite_stack_app_on_uncommitted_layer(); + + app.refresh(); + + assert_eq!( + app.cur().cs.span, + ChangesetSpan::Uncommitted, + "refresh must keep the reviewer on the uncommitted layer, not its same-named \ + committed changeset" + ); + } + + /// The confirm-time re-resolve for an outline discard looks the changeset up by NAME alone + /// (`resolve_confirm`'s `DiscardOutlineFiles` arm) — the first match is the committed "b" + /// changeset, the file isn't in ITS diff, and the pair is silently dropped: `y` does + /// nothing. This is the "discard from the outline has no effect" dogfood bug. + #[test] + fn outline_discard_still_applies_when_a_committed_changeset_shares_the_layers_name() { + let (fixture, mut app) = graphite_stack_app_on_uncommitted_layer(); + // Set mode/order BEFORE the row lookup — the index is only valid in the build it was + // found in. + open_focused_outline(&mut app, OutlineMode::Stack, 0); + app.outline.cursor = outline_file_row(&app, "scratch.txt"); + + app.outline_discard(); + assert!( + app.pending_confirm.is_some(), + "discard must request confirm; notice: {:?}", + app.notice + ); + app.resolve_confirm(true); + + let repo = fixture.repo().unwrap(); + let scratch = repo.workdir().unwrap().join("scratch.txt"); + // No absence predicate exists yet; a direct existence check keeps the assertion honest. + assert!( + !scratch.exists(), + "y must discard the untracked file from the worktree" + ); + } + #[test] fn outline_discard_confirm_n_cancels_and_leaves_the_worktree_unchanged() { let fixture = FixtureBuilder::new() From d941ccc9a67377138b9113efa1d09b9955003b57 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 13 Jul 2026 19:09:36 -0400 Subject: [PATCH 140/203] test: merge per-file integration tests into one harness per crate --- Makefile | 9 +++++---- git-workon-review/tests/pty/main.rs | 12 ++++++++++++ .../tests/{ => pty}/pty_responsiveness.rs | 13 +++++-------- git-workon-review/tests/{ => pty}/pty_smoke.rs | 11 ++++------- .../tests/{ => pty}/pty_support/mod.rs | 12 +++++++----- git-workon-review/tests/{ => suite}/apply.rs | 0 git-workon-review/tests/{ => suite}/cli.rs | 0 git-workon-review/tests/{ => suite}/diff_model.rs | 0 git-workon-review/tests/{ => suite}/file_ops.rs | 0 .../tests/{ => suite}/line_synthesis.rs | 0 git-workon-review/tests/suite/main.rs | 14 ++++++++++++++ .../tests/{ => suite}/roundtrip_corpus.rs | 0 git-workon-review/tests/{ => suite}/source.rs | 0 .../tests/{ => suite}/treesitter_smoke.rs | 0 14 files changed, 47 insertions(+), 24 deletions(-) create mode 100644 git-workon-review/tests/pty/main.rs rename git-workon-review/tests/{ => pty}/pty_responsiveness.rs (98%) rename git-workon-review/tests/{ => pty}/pty_smoke.rs (96%) rename git-workon-review/tests/{ => pty}/pty_support/mod.rs (78%) rename git-workon-review/tests/{ => suite}/apply.rs (100%) rename git-workon-review/tests/{ => suite}/cli.rs (100%) rename git-workon-review/tests/{ => suite}/diff_model.rs (100%) rename git-workon-review/tests/{ => suite}/file_ops.rs (100%) rename git-workon-review/tests/{ => suite}/line_synthesis.rs (100%) create mode 100644 git-workon-review/tests/suite/main.rs rename git-workon-review/tests/{ => suite}/roundtrip_corpus.rs (100%) rename git-workon-review/tests/{ => suite}/source.rs (100%) rename git-workon-review/tests/{ => suite}/treesitter_smoke.rs (100%) diff --git a/Makefile b/Makefile index 316c8ab..3ce8424 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/git-workon-review/tests/pty/main.rs b/git-workon-review/tests/pty/main.rs new file mode 100644 index 0000000..ee557f7 --- /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 851054a..e422691 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 96% rename from git-workon-review/tests/pty_smoke.rs rename to git-workon-review/tests/pty/pty_smoke.rs index 0d9902f..3b3e9ef 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::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 diff --git a/git-workon-review/tests/pty_support/mod.rs b/git-workon-review/tests/pty/pty_support/mod.rs similarity index 78% rename from git-workon-review/tests/pty_support/mod.rs rename to git-workon-review/tests/pty/pty_support/mod.rs index 1887a73..a38434a 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; 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 100% rename from git-workon-review/tests/file_ops.rs rename to git-workon-review/tests/suite/file_ops.rs 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 0000000..0e92ba0 --- /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 100% rename from git-workon-review/tests/roundtrip_corpus.rs rename to git-workon-review/tests/suite/roundtrip_corpus.rs 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 From f70a7b575e022ea4853bf76f2e3c2dd1577aecbc Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 14 Jul 2026 00:57:07 -0400 Subject: [PATCH 141/203] fix(review): clear clippy lint debt blocking the scoped stop gate if_same_then_else in app.rs (merge the n==14 arm into the identical n==2||n==10 branch) and two useless_vec in summary.rs tests. These pre-existing lints would red-block the new scoped stop gate once per code state on every turn that touches git-workon-review. --- git-workon-review/src/app.rs | 4 +--- git-workon-review/src/summary.rs | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index af18847..7442b3e 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -7352,9 +7352,7 @@ mod tests { .collect(); let worktree: String = (1..=14) .map(|n| { - if n == 2 || n == 10 { - format!("L{n}X\n") - } else if n == 14 { + if n == 2 || n == 10 || n == 14 { format!("L{n}X\n") } else { format!("L{n}\n") diff --git a/git-workon-review/src/summary.rs b/git-workon-review/src/summary.rs index a34f4cd..8e80f8b 100644 --- a/git-workon-review/src/summary.rs +++ b/git-workon-review/src/summary.rs @@ -270,7 +270,7 @@ mod tests { #[test] fn dir_summary_filters_by_segment_boundary_not_raw_prefix() { - let files = vec![ + let files = [ file("src/a.rs", 1, 0, 0), file("src/b.rs", 0, 1, 0), file("src2/b.rs", 5, 5, 0), @@ -289,7 +289,7 @@ mod tests { #[test] fn dir_summary_matches_nested_paths_under_the_dir() { - let files = vec![file("src/a/b.rs", 2, 0, 0), file("src/c.rs", 0, 2, 0)]; + let files = [file("src/a/b.rs", 2, 0, 0), file("src/c.rs", 0, 2, 0)]; let summary = dir_summary("src".to_string(), &files.iter().collect::>()); assert_eq!(summary.files.len(), 2); assert_eq!(summary.total_adds, 2); From 21a0005e1e4caf7ec1bcf92e9a9acddce107e70a Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 13:33:04 -0400 Subject: [PATCH 142/203] feat(review): diff gap fold-all keys and n/p hunk navigation --- git-workon-review/src/app.rs | 240 ++++++++++++++++++++++++++++++-- git-workon-review/src/keymap.rs | 104 +++++++++++++- git-workon-review/src/render.rs | 2 +- git-workon-review/src/tui.rs | 39 +++++- 4 files changed, 370 insertions(+), 15 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 7442b3e..277ce95 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,7 @@ pub enum Role { Staged, } -/// The zoom the user *requested* via `z` — persists across file navigation (like [`Layout`]). The +/// 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. @@ -1204,7 +1246,7 @@ 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, /// Which split pane has focus. Only meaningful under [`EffectiveZoom::Split`]; reset to @@ -2225,7 +2267,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 +2399,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) { @@ -3790,6 +3832,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 @@ -3985,7 +4062,7 @@ impl App { ); } else { self.notify( - format!("{verb} in the unstaged/staged pane — cycle zoom (z)"), + format!("{verb} in the unstaged/staged pane — cycle zoom (Z)"), Severity::Error, ); } @@ -4675,7 +4752,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 { @@ -5329,7 +5406,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!( @@ -11218,6 +11295,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/keymap.rs b/git-workon-review/src/keymap.rs index fd61def..8fa3628 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -65,6 +65,8 @@ pub enum Command { PrevChangeset, ExpandGap, ExpandGapAll, + ResetGaps, + ExpandAllGaps, HscrollLeft, HscrollRight, // Diff view. @@ -183,7 +185,11 @@ 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). `Z` was free in `View::Diff`. + default_keys: "Z", description: "Cycle the staged/unstaged zoom", }, Registered { @@ -253,14 +259,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 +318,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, @@ -1170,6 +1190,84 @@ 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 warning (`build_context`'s + /// collision check only flags identical sequences, not prefix overlaps, so it wouldn't catch + /// this). 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. + #[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 { diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index ce1a379..77d883b 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -2593,7 +2593,7 @@ mod tests { 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() diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index f1dac88..a211822 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -447,6 +447,8 @@ enum Action { StartSelection, ExpandGap, ExpandGapAll, + ResetGaps, + ExpandAllGaps, HscrollLeft, HscrollRight, ToggleOutline, @@ -495,6 +497,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 +597,8 @@ fn action_needs_loaded_view(action: Action) -> bool { | Action::ToggleSplitFocus | Action::ExpandGap | Action::ExpandGapAll + | Action::ResetGaps + | Action::ExpandAllGaps ) } @@ -630,6 +636,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(), @@ -1553,11 +1561,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 +1577,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(); From 3e560d603819322fe302775760fe35d2ef71eb15 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 22:13:51 -0400 Subject: [PATCH 143/203] feat(review): warn at startup when a bare key shadows under a chord prefix --- git-workon-review/src/keymap.rs | 96 ++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 8fa3628..a5d22e8 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 > @@ -188,7 +190,8 @@ pub static REGISTRY: &[Registered] = &[ // 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). `Z` was free in `View::Diff`. + // 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", }, @@ -755,7 +758,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, @@ -777,6 +786,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)); } } @@ -784,6 +812,31 @@ fn build_context( out } +/// True when `shorter` is strictly shorter than `longer` AND is its leading sub-sequence. +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)] @@ -1220,10 +1273,13 @@ mod tests { /// (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 warning (`build_context`'s - /// collision check only flags identical sequences, not prefix overlaps, so it wouldn't catch - /// this). This test pins the resolved state: `Z` fires `CycleZoom` immediately, and `z` only + /// 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(); @@ -1350,6 +1406,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] From 364fb6e6b3af6a2159b3046ddf97045430212445 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 22:55:21 -0400 Subject: [PATCH 144/203] refactor(review): share the strict-prefix test with key dispatch --- git-workon-review/src/keymap.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index a5d22e8..7d3fcbc 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -740,7 +740,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); @@ -813,6 +813,9 @@ fn build_context( } /// 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 } From 8e6e8d65a1192fda817d7ca6b4e04007340ee5ad Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 22:25:14 -0400 Subject: [PATCH 145/203] fix(review): refusal notice shows the resolved cycle-zoom key --- git-workon-review/src/app.rs | 54 ++++++++++++++++++++++++++++++++++- git-workon-review/src/main.rs | 12 +++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 277ce95..95beeea 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1346,6 +1346,13 @@ pub struct App { /// touches a thread or a `Repository`-carrying `Sender` itself, so it stays constructible (and /// `refresh` stays synchronously testable) with nothing wired up to actually dispatch this. pending_wave: Option<(u64, Vec<(usize, Changeset)>)>, + /// Display label for the resolved [`crate::keymap::Command::CycleZoom`] binding, shown in + /// [`Self::notify_combined_refusal`]'s "cycle zoom" hint. `App` deliberately has no keymap + /// field (the keymap is threaded through `tui.rs`/`main.rs` separately), so `main.rs::seat_app` + /// sets this once at seat time from the resolved binding; defaults to `"Z"` — the command's + /// default binding — for every `App::new`/`from_changesets` path that never seats a keymap + /// (keeps existing unit tests passing without churn). + zoom_key_label: String, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -1526,6 +1533,7 @@ impl App { generation: 1, wave_failure_notified: false, pending_wave: None, + zoom_key_label: "Z".to_string(), }; // 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 @@ -1544,6 +1552,13 @@ 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::seat_app` calls this with the + /// resolved [`crate::keymap::Command::CycleZoom`] binding right after construction. + 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 @@ -4061,8 +4076,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, ); } @@ -7641,6 +7657,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}; diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 4e7fa06..b8142c2 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -8,7 +8,7 @@ use miette::{IntoDiagnostic, Result}; use workon_review::acquire::{diff_changesets, resolve_changesets}; use workon_review::app::{App, ChangesetView, Severity}; use workon_review::config::{self, ReviewConfig}; -use workon_review::keymap::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; @@ -222,6 +222,16 @@ fn seat_app( if let Some(source) = source { app.set_review_source(source); } + // Plumb the resolved CycleZoom binding into the "cycle zoom" refusal hint (App has no keymap + // field of its own — see `App::zoom_key_label`'s doc comment); leaves the "Z" default in + // place if the command has no bound key. + if let Some(label) = keymap + .keys_for(Command::CycleZoom) + .first() + .map(|seq| keymap::render_seq(seq)) + { + app.set_zoom_key_label(label); + } // CS4: defer file loads to the event loop's input-idle window rather than blocking here (or // on any later selection change) — `app.open_current()` below marks the initial open pending // instead of loading eagerly; see `tui::run`'s doc comment for the resulting startup From a93ba1eeca4f7d001f65fcaa79659cd8ca27d091 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 22:57:53 -0400 Subject: [PATCH 146/203] refactor(review): reuse primary_key for the zoom refusal label --- git-workon-review/src/keymap.rs | 6 ++++-- git-workon-review/src/main.rs | 6 +----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 7d3fcbc..1943bd3 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -904,8 +904,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)) } diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index b8142c2..6bcea12 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -225,11 +225,7 @@ fn seat_app( // Plumb the resolved CycleZoom binding into the "cycle zoom" refusal hint (App has no keymap // field of its own — see `App::zoom_key_label`'s doc comment); leaves the "Z" default in // place if the command has no bound key. - if let Some(label) = keymap - .keys_for(Command::CycleZoom) - .first() - .map(|seq| keymap::render_seq(seq)) - { + if let Some(label) = keymap::primary_key(keymap, Command::CycleZoom) { app.set_zoom_key_label(label); } // CS4: defer file loads to the event loop's input-idle window rather than blocking here (or From a421bdf8fc68489a0ec0f5f4670857728d221473 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 18:29:16 -0400 Subject: [PATCH 147/203] feat(review): base16 slot and tint override keys under workon.review.theme --- .../028-review-git-native-config-schema.md | 8 + docs/adr/029-review-theming-base16-hybrid.md | 41 +++ git-workon-review/src/config.rs | 218 +++++++++++++++ git-workon-review/src/main.rs | 50 +++- git-workon-review/src/theme.rs | 249 ++++++++++++++++++ 5 files changed, 556 insertions(+), 10 deletions(-) diff --git a/docs/adr/028-review-git-native-config-schema.md b/docs/adr/028-review-git-native-config-schema.md index 52e9d34..065f8a2 100644 --- a/docs/adr/028-review-git-native-config-schema.md +++ b/docs/adr/028-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-029](029-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 diff --git a/docs/adr/029-review-theming-base16-hybrid.md b/docs/adr/029-review-theming-base16-hybrid.md index 7759213..6058a08 100644 --- a/docs/adr/029-review-theming-base16-hybrid.md +++ b/docs/adr/029-review-theming-base16-hybrid.md @@ -144,6 +144,47 @@ 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`, `outline-cursor-unfocused-bg` | 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-028'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. + ## References - [ADR-028](028-review-git-native-config-schema.md) — `workon.review.theme` config key diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index 4b52ee1..00e003e 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) //! @@ -50,6 +55,9 @@ //! [`crate::icons`] for the glyph table. use git2::Repository; +use ratatui::style::Color; + +use crate::theme::{self, ThemeOverrides}; /// Which view a keybinding or view-setting applies to. /// @@ -138,6 +146,45 @@ 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-subtle" => &mut overrides.del_subtle, + "del-strong" => &mut overrides.del_strong, + "add-subtle" => &mut overrides.add_subtle, + "add-strong" => &mut overrides.add_strong, + "del-staged-subtle" => &mut overrides.del_staged_subtle, + "del-staged-strong" => &mut overrides.del_staged_strong, + "add-staged-subtle" => &mut overrides.add_staged_subtle, + "add-staged-strong" => &mut overrides.add_staged_strong, + "cursor-bg" => &mut overrides.cursor_bg, + "selection-bg" => &mut overrides.selection_bg, + "outline-cursor-unfocused-bg" => &mut overrides.outline_cursor_unfocused_bg, + _ => 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`]. +fn invalid_color_warning(key: &str, raw: &str) -> String { + format!("workon.review.theme.{key}: invalid color {raw:?}, ignoring") +} + /// Configuration reader for `workon.review.*` settings stored in git config. /// /// Mirrors `git-workon-lib`'s `WorkonConfig`: opens the repository's layered config (local > @@ -168,6 +215,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 @@ -481,4 +593,110 @@ mod tests { assert_eq!(config.diff_layout().expect("layout"), None); assert_eq!(config.diff_zoom().expect("zoom"), 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_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); + assert!(warnings[0].contains("base00")); + } + + #[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"))); + } + + #[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()); + } } diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 6bcea12..8ab544b 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -100,12 +100,26 @@ fn main() -> Result<()> { // 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 (mut theme, probed) = match selection { Ok(config::Theme::Auto) => terminal_query::detect_auto_palette(), Ok(selection) => (Palette::for_theme(selection), false), Err(_) => (Palette::dark(), false), }; + // CS1 (user-configurable colors tier): apply any `workon.review.theme.*` slot/tint + // overrides on top of whichever base was just resolved above — works uniformly on + // `dark`/`light`/`auto`'s probe result (see `Palette::apply_overrides`'s doc comment). A + // config-read error degrades to no overrides, same posture as every other getter here; a + // malformed value or unknown key is collected as a warning and joined into the startup + // notice in `seat_app` below, alongside the keymap/view-config warnings. + let theme_override_warnings = match ReviewConfig::new(&repo).theme_overrides() { + Ok((overrides, warnings)) => { + theme.apply_overrides(&overrides); + warnings + } + Err(_) => Vec::new(), + }; + // 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 @@ -177,7 +191,14 @@ 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. @@ -195,7 +216,14 @@ 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) @@ -207,16 +235,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 { @@ -241,11 +270,12 @@ 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. let mut warnings = keymap.warnings().to_vec(); warnings.extend(view_config_warnings); + warnings.extend(theme_override_warnings.iter().cloned()); if !warnings.is_empty() { app.notify(warnings.join("; "), Severity::Error); } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 8721a13..44b36fe 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -38,6 +38,14 @@ //! [`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-subtle`, `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-029's CS1 revision note for the full table. use ratatui::style::Color; @@ -105,6 +113,57 @@ 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 11 tint fields mirror +/// [`Palette`]'s diff/cursor tint fields verbatim (same names, kebab-case in config). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ThemeOverrides { + slots: [Option; 16], + pub del_subtle: Option, + pub del_strong: Option, + pub add_subtle: Option, + pub add_strong: Option, + pub del_staged_subtle: Option, + pub del_staged_strong: Option, + pub add_staged_subtle: Option, + pub add_staged_strong: Option, + pub cursor_bg: Option, + pub selection_bg: Option, + pub outline_cursor_unfocused_bg: 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-029 /// describes for a LIGHT base00: blending an accent toward a light background yields a pale, @@ -423,6 +482,98 @@ 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), 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. + /// + /// Slot overrides do NOT re-derive the diff/cursor tints — that stays the 11 tint override + /// keys' job, applied last and verbatim below, 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[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_subtle { + self.del_subtle = color; + } + if let Some(color) = overrides.del_strong { + self.del_strong = color; + } + if let Some(color) = overrides.add_subtle { + self.add_subtle = color; + } + if let Some(color) = overrides.add_strong { + self.add_strong = color; + } + if let Some(color) = overrides.del_staged_subtle { + self.del_staged_subtle = color; + } + if let Some(color) = overrides.del_staged_strong { + self.del_staged_strong = color; + } + if let Some(color) = overrides.add_staged_subtle { + self.add_staged_subtle = color; + } + if let Some(color) = overrides.add_staged_strong { + self.add_staged_strong = 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.outline_cursor_unfocused_bg { + self.outline_cursor_unfocused_bg = color; + } + } } #[cfg(test)] @@ -743,4 +894,102 @@ mod tests { Palette::dark().del_subtle ); } + + #[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_subtle, before.del_subtle); + 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_subtle + 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_subtle, Palette::dark().del_subtle); + } } From 60a9ddde7e3793ea90768df2f933fd1fda23eae9 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 18:45:10 -0400 Subject: [PATCH 148/203] feat(review): render monochrome when NO_COLOR is set --- docs/adr/029-review-theming-base16-hybrid.md | 18 ++ git-workon-review/src/main.rs | 49 ++++- git-workon-review/src/theme.rs | 181 ++++++++++++++++++- 3 files changed, 245 insertions(+), 3 deletions(-) diff --git a/docs/adr/029-review-theming-base16-hybrid.md b/docs/adr/029-review-theming-base16-hybrid.md index 6058a08..82dd8b5 100644 --- a/docs/adr/029-review-theming-base16-hybrid.md +++ b/docs/adr/029-review-theming-base16-hybrid.md @@ -185,6 +185,24 @@ to vendor) was considered and set aside — the override-key tier covers the imm named schemes slot in additively later (a `Theme::Named` variant + a `schemes.rs` of vendored constants) without touching this work if demand appears. +**NO_COLOR (CS2).** The other extra this tier's Context section named — `NO_COLOR`, no CLI flag, +no color-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. + ## References - [ADR-028](028-review-git-native-config-schema.md) — `workon.review.theme` config key diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 8ab544b..198f34a 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; @@ -11,7 +13,17 @@ use workon_review::config::{self, ReviewConfig}; 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)] @@ -120,6 +132,21 @@ fn main() -> Result<()> { Err(_) => Vec::new(), }; + // CS2 (`no-color-mono`): `NO_COLOR` is an env kill-switch — it wins over any override + // applied just above, so it must be checked last, after resolution AND overrides. The + // ladder choice (`is_light_background`) reads the pre-mono `theme.background` so `auto`'s + // probe still picks the right dark/pale ladder. `FORCE_COLOR` is deliberately not consulted + // (see `no_color`'s doc comment). + if no_color(std::env::var_os("NO_COLOR").as_deref()) { + theme = Palette::mono(theme::is_light_background(theme.background)); + // 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. + crossterm::style::force_color_output(true); + } + // 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 @@ -282,3 +309,23 @@ fn seat_app( 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/theme.rs b/git-workon-review/src/theme.rs index 44b36fe..682a09c 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -46,6 +46,12 @@ //! [`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-029'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-029's +//! CS2 revision note. use ratatui::style::Color; @@ -185,8 +191,9 @@ pub(crate) fn tint_toward(color: Color, base: Color, ratio: f32) -> Color { /// 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, @@ -460,6 +467,80 @@ impl Palette { } } + /// 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: subtle vs strong 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-029's NO_COLOR note). + pub fn mono(light: bool) -> Self { + // (subtle, strong, staged_subtle, staged_strong, cursor, selection, outline_cursor_unfocused) + let ( + subtle, + strong, + staged_subtle, + staged_strong, + cursor, + selection, + outline_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_subtle: subtle, + del_strong: strong, + add_subtle: subtle, + add_strong: strong, + del_staged_subtle: staged_subtle, + del_staged_strong: staged_strong, + add_staged_subtle: staged_subtle, + add_staged_strong: staged_strong, + cursor_bg: cursor, + selection_bg: selection, + outline_cursor_unfocused_bg: outline_cursor_unfocused, + background: Color::Reset, + foreground: Color::Reset, + dim: Color::Reset, + gutter: 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, + } + } + /// The syntax foreground for a capture index (position in /// [`crate::highlight::HIGHLIGHT_NAMES`]). This is the render-time resolution the whole /// mechanism turns on: [`crate::highlight::FgSpan`] carries the index, the renderer resolves @@ -992,4 +1073,100 @@ mod tests { // only thing that moves them. assert_eq!(t.del_subtle, Palette::dark().del_subtle); } + + #[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_subtle, + t.del_strong, + t.add_subtle, + t.add_strong, + t.del_staged_subtle, + t.del_staged_strong, + t.add_staged_subtle, + t.add_staged_strong, + t.cursor_bg, + t.selection_bg, + t.outline_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_subtle, t.add_subtle); + assert_eq!(t.del_strong, t.add_strong); + assert_eq!(t.del_staged_subtle, t.add_staged_subtle); + assert_eq!(t.del_staged_strong, t.add_staged_strong); + + // Subtle vs strong remain visibly distinct steps. + assert_ne!(t.del_subtle, t.del_strong); + assert_ne!(t.del_staged_subtle, t.del_staged_strong); + + // Staged reads dimmer (closer to the implied background — brighter grays near a + // light bg, darker grays near a dark bg) than unstaged. + let (staged_subtle, _, _) = rgb(t.del_staged_subtle); + let (subtle, _, _) = rgb(t.del_subtle); + let (staged_strong, _, _) = rgb(t.del_staged_strong); + let (strong, _, _) = rgb(t.del_strong); + if light { + assert!(staged_subtle > subtle, "staged should sit closer to white"); + assert!(staged_strong > strong, "staged should sit closer to white"); + } else { + assert!(staged_subtle < subtle, "staged should sit closer to black"); + assert!(staged_strong < strong, "staged should sit closer to black"); + } + + // Cursor vs selection are distinct, and the unfocused outline cursor 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 (outline_unfocused, _, _) = rgb(t.outline_cursor_unfocused_bg); + if light { + assert!(outline_unfocused > cursor); + } else { + assert!(outline_unfocused < cursor); + } + } + } + + #[test] + fn mono_dark_ladder_sits_near_black_and_light_ladder_near_white() { + let (r, _, _) = rgb(Palette::mono(false).del_strong); + assert!(r < 128, "dark ladder should be a dark gray"); + let (r, _, _) = rgb(Palette::mono(true).del_strong); + assert!(r > 128, "light ladder should be a pale gray"); + } } From 7e754ba2e3d7d1edf0e7811a30b9fb8d8c96ecd0 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 19:17:40 -0400 Subject: [PATCH 149/203] fix(review): collapse nerd icon colors to foreground under NO_COLOR --- docs/adr/029-review-theming-base16-hybrid.md | 6 +- git-workon-review/src/render.rs | 90 +++++++++++++++++++- git-workon-review/src/theme.rs | 23 +++++ 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/docs/adr/029-review-theming-base16-hybrid.md b/docs/adr/029-review-theming-base16-hybrid.md index 82dd8b5..5751c60 100644 --- a/docs/adr/029-review-theming-base16-hybrid.md +++ b/docs/adr/029-review-theming-base16-hybrid.md @@ -201,7 +201,11 @@ found by driving the real binary under a PTY: crossterm ALSO honors `NO_COLOR`, 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 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. ## References diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 77d883b..5927ce0 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -1015,9 +1015,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/ @@ -1180,11 +1189,16 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { &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), )); } } @@ -4579,6 +4593,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() diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 682a09c..1fb0674 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -319,6 +319,13 @@ 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, } impl Palette { @@ -361,6 +368,7 @@ impl Palette { // directly rather than an authored literal. modified_fg: base.slot(9), paint_canvas: true, + colorless: false, } } @@ -415,6 +423,7 @@ impl Palette { heading_fg: cyan, modified_fg: base.slot(9), // base09 paint_canvas: true, + colorless: false, } } @@ -464,6 +473,7 @@ 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, } } @@ -538,6 +548,7 @@ impl Palette { heading_fg: Color::Reset, modified_fg: Color::Reset, paint_canvas: false, + colorless: true, } } @@ -1162,6 +1173,18 @@ mod tests { } } + #[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_strong); From 0822697793fd3520d08b6e50b49ff75e80f97e4a Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 21:54:47 -0400 Subject: [PATCH 150/203] feat(review): per-pane headers replace the global winbar row --- git-workon-review/src/render.rs | 803 +++++++++++++++++++++++++------- 1 file changed, 647 insertions(+), 156 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 5927ce0..ffc72f7 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 @@ -60,9 +60,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, @@ -590,9 +590,10 @@ fn build_pane_line( } } -/// 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 +618,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 +646,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 +734,96 @@ 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 outline pane's own top row (CS1, `pane-headers`): `[i/n] {display_label}` (the active +/// changeset's TRUE stack position, `theme.heading_fg` bold label — 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-031). 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) { + 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}] "), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), + ), + TSpan::styled( + title, + Style::default() + .fg(theme.heading_fg) + .add_modifier(Modifier::BOLD), + ), + ]; + 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-031) — 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)); + let (added_prefix, removed_prefix) = diffstat_prefixes(icons); + spans.push(TSpan::styled( + " ".to_string(), + Style::default().fg(theme.foreground), + )); + spans.push(TSpan::styled( + format!("{added_prefix}{adds}"), + Style::default() + .fg(theme.add_strong) + .add_modifier(Modifier::BOLD), + )); + spans.push(TSpan::styled( + " ".to_string(), + Style::default().fg(theme.foreground), + )); + spans.push(TSpan::styled( + format!("{removed_prefix}{dels}"), + Style::default() + .fg(theme.del_strong) + .add_modifier(Modifier::BOLD), + )); + } + 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 @@ -752,6 +840,14 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// 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); + 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(); @@ -1062,9 +1158,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 => { @@ -1080,32 +1176,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 { @@ -1117,23 +1189,19 @@ 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}` +/// bold, 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`]. +fn changeset_prefix_spans(app: &App, theme: &Palette, icons: IconMode) -> 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}"), @@ -1145,44 +1213,31 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { // 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-031) — 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(), + spans +} + +/// The diff pane header's shared "current file" segment (CS1, `pane-headers`): `[fidx/nfiles] ` +/// bold, an optional nerd devicons file icon, [`current_file_label`] bold, 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. +fn file_segment_spans(app: &App, theme: &Palette, icons: IconMode) -> Vec> { + let idx = app.current + 1; + let n = app.files().len(); + let mut spans = vec![TSpan::styled( + format!("[{idx}/{n}] "), Style::default() .fg(theme.foreground) .add_modifier(Modifier::BOLD), - )); + )]; if icons == IconMode::Nerd { if let Some(f) = app.files().get(app.current) { let (icon, color) = crate::icons::icon_for_path( @@ -1203,16 +1258,86 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { } } spans.push(TSpan::styled( - format!("{} ({fidx}/{nfiles})", current_file_label(app)), + current_file_label(app), Style::default() .fg(theme.foreground) .add_modifier(Modifier::BOLD), )); + if let Some(f) = app.files().get(app.current) { + let (adds, dels) = crate::summary::file_diffstat(f); + let (added_prefix, removed_prefix) = diffstat_prefixes(icons); + spans.push(TSpan::styled( + " ".to_string(), + Style::default().fg(theme.foreground), + )); + spans.push(TSpan::styled( + format!("{added_prefix}{adds}"), + Style::default() + .fg(theme.add_strong) + .add_modifier(Modifier::BOLD), + )); + spans.push(TSpan::styled( + " ".to_string(), + Style::default().fg(theme.foreground), + )); + spans.push(TSpan::styled( + format!("{removed_prefix}{dels}"), + Style::default() + .fg(theme.del_strong) + .add_modifier(Modifier::BOLD), + )); + } if let Some(span) = hscroll_indicator_span(app, theme) { spans.push(span); } + spans +} - frame.render_widget(Paragraph::new(Line::from(spans)), area); +/// 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-031): 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`). +fn diff_header_line(app: &App, theme: &Palette, icons: IconMode) -> 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)) + } else { + Line::from(TSpan::styled( + " ".to_string(), + Style::default().fg(theme.foreground), + )) + }; + } + + let mut spans = Vec::new(); + if show_prefix { + spans.extend(changeset_prefix_spans(app, theme, icons)); + spans.push(TSpan::styled( + " — ".to_string(), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), + )); + } + spans.extend(file_segment_spans(app, theme, icons)); + Line::from(spans) } /// Footer priority: a pending discard confirm's prompt (warn-toned) wins over a transient notice, @@ -1403,28 +1528,29 @@ fn push_summary_body( ])); } -/// 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 @@ -1434,14 +1560,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( @@ -1453,29 +1579,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, @@ -1485,39 +1614,74 @@ 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 { + let line = diff_header_line(app, theme, icons); + frame + .buffer_mut() + .set_line(header_area.x, header_area.y, &line, header_area.width); + } // ADR-031: 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 @@ -2917,9 +3081,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`). @@ -2980,15 +3144,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]"), @@ -3002,55 +3170,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("b.txt") && header.contains("(1/1)"), - "expected the active file's path and position, got: {header:?}" + header.contains("+1") && header.contains("-0"), + "expected a tight '+N -M' per-file diffstat fragment, got: {header:?}" + ); + assert!( + !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); @@ -3061,22 +3281,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(); @@ -3091,7 +3312,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") @@ -3107,7 +3328,110 @@ 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 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 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-031 + 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:?}" ); } @@ -3366,7 +3690,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() @@ -3375,22 +3702,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:?}" @@ -3593,6 +3922,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 @@ -3614,11 +4047,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)) @@ -3634,6 +4068,11 @@ mod tests { .iter() .position(|r| r.contains("cs-b")) .expect("summary panel's title (cs-b's label) present"); + assert_eq!( + row, 0, + "the summary panel's title now paints the diff pane's header row (y=0), got row \ + {row} instead:\n{joined}" + ); // `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), @@ -3647,7 +4086,7 @@ mod tests { as u16 + 36; assert_eq!( - buf.cell((label_x, row as u16 + 1)).unwrap().style().fg, + 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" @@ -4815,6 +5254,58 @@ mod tests { .join("\n") } + #[test] + fn summary_header_shows_dir_title_and_body_drops_duplicate() { + // CS1: `dir_summary_lines` now returns `(title, body)` — the title paints the diff + // pane'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 = 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::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() From ccd925c79f5fcbdd8a8fd19ff9e180e68a472b73 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 22:53:39 -0400 Subject: [PATCH 151/203] refactor(review): single-source the pane-header diffstat spans --- git-workon-review/src/render.rs | 74 ++++++++++++++------------------- 1 file changed, 32 insertions(+), 42 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index ffc72f7..4555dee 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -106,6 +106,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_strong) + .add_modifier(Modifier::BOLD), + ), + TSpan::styled(" ".to_string(), Style::default().fg(theme.foreground)), + TSpan::styled( + format!("{removed_prefix}{dels}"), + Style::default() + .fg(theme.del_strong) + .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, @@ -786,27 +816,7 @@ fn render_outline_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palet .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::styled( - " ".to_string(), - Style::default().fg(theme.foreground), - )); - spans.push(TSpan::styled( - format!("{added_prefix}{adds}"), - Style::default() - .fg(theme.add_strong) - .add_modifier(Modifier::BOLD), - )); - spans.push(TSpan::styled( - " ".to_string(), - Style::default().fg(theme.foreground), - )); - spans.push(TSpan::styled( - format!("{removed_prefix}{dels}"), - Style::default() - .fg(theme.del_strong) - .add_modifier(Modifier::BOLD), - )); + spans.extend(diffstat_spans(adds, dels, theme, icons)); } let line = Line::from(spans); frame @@ -1265,27 +1275,7 @@ fn file_segment_spans(app: &App, theme: &Palette, icons: IconMode) -> Vec Date: Thu, 16 Jul 2026 15:39:44 -0400 Subject: [PATCH 152/203] feat(review): light the focused pane's header label --- docs/adr/029-review-theming-base16-hybrid.md | 2 +- git-workon-review/src/config.rs | 24 + git-workon-review/src/render.rs | 495 ++++++++++++++++--- git-workon-review/src/theme.rs | 41 ++ 4 files changed, 502 insertions(+), 60 deletions(-) diff --git a/docs/adr/029-review-theming-base16-hybrid.md b/docs/adr/029-review-theming-base16-hybrid.md index 5751c60..07dd20f 100644 --- a/docs/adr/029-review-theming-base16-hybrid.md +++ b/docs/adr/029-review-theming-base16-hybrid.md @@ -164,7 +164,7 @@ override keys**, not named bundled schemes. `workon.review.theme.*` (a subsectio | `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`, `outline-cursor-unfocused-bg` | diff/cursor tint override (kebab-case, mirroring the `Palette` field names) | the matching field, verbatim | +| `del-subtle`, `del-strong`, `add-subtle`, `add-strong`, `del-staged-subtle`, `del-staged-strong`, `add-staged-subtle`, `add-staged-strong`, `cursor-bg`, `selection-bg`, `outline-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 diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index 00e003e..e33f287 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -175,6 +175,7 @@ fn tint_slot<'a>(overrides: &'a mut ThemeOverrides, key: &str) -> Option<&'a mut "cursor-bg" => &mut overrides.cursor_bg, "selection-bg" => &mut overrides.selection_bg, "outline-cursor-unfocused-bg" => &mut overrides.outline_cursor_unfocused_bg, + "pane-header-focused-fg" => &mut overrides.pane_header_focused_fg, _ => return None, }) } @@ -627,6 +628,29 @@ mod tests { assert_eq!(overrides.cursor_bg, Some(Color::Rgb(0x1a, 0x2b, 0x3c))); } + #[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_slot_keys_are_case_insensitive() { use crate::theme::Palette; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 4555dee..6395720 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -764,14 +764,36 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect frame.render_widget(Paragraph::new(lines).block(block), popup_area); } +/// The style for a pane header/caption LABEL word (CS1, `focused-pane-header`) — never the +/// surrounding chrome (counters, diffstats, rule characters, markers), which keep their own +/// existing colors regardless of focus (locked decision #4). `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 call +/// site across a frame's outline header / diff header / split captions should ever pass `true` +/// (the exactly-one-lit-label invariant — see the module's `focused-pane-header` handoff). +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, `theme.heading_fg` bold label — 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-031). Truncated to the outline's -/// own width via [`Buffer::set_line`], exactly like every outline item row below it. +/// changeset's TRUE stack position, the display label styled via [`pane_header_label_style`] — +/// 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-031). 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 @@ -779,7 +801,7 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// 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) { +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(); @@ -793,12 +815,7 @@ fn render_outline_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palet .fg(theme.foreground) .add_modifier(Modifier::BOLD), ), - TSpan::styled( - title, - Style::default() - .fg(theme.heading_fg) - .add_modifier(Modifier::BOLD), - ), + TSpan::styled(title, pane_header_label_style(theme, focused)), ]; if cs.needs_restack { spans.push(TSpan::styled( @@ -853,7 +870,7 @@ 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); + render_outline_header(frame, app, area, theme, app.outline_focused()); Rect::new(area.x, area.y + 1, area.width, area.height - 1) } else { area @@ -1233,13 +1250,24 @@ fn changeset_prefix_spans(app: &App, theme: &Palette, icons: IconMode) -> Vec Vec> { +/// bold, an optional nerd devicons file icon, [`current_file_label`] styled via +/// [`pane_header_label_style`] (lit while `focused`, dim otherwise — CS1, `focused-pane-header`), +/// 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( @@ -1269,9 +1297,7 @@ fn file_segment_spans(app: &App, theme: &Palette, icons: IconMode) -> Vec Vec Line<'static> { +/// +/// `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() { @@ -1326,7 +1357,7 @@ fn diff_header_line(app: &App, theme: &Palette, icons: IconMode) -> Line<'static .add_modifier(Modifier::BOLD), )); } - spans.extend(file_segment_spans(app, theme, icons)); + spans.extend(file_segment_spans(app, theme, icons, focused)); Line::from(spans) } @@ -1667,7 +1698,25 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { } if let Some(header_area) = header_area { - let line = diff_header_line(app, theme, icons); + // 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); @@ -1747,6 +1796,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 { @@ -1790,8 +1845,20 @@ 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); + render_caption( + frame.buffer_mut(), + unstaged_caption, + "UNSTAGED", + theme, + !outline_focused && app.split_focus_role() == Role::Unstaged, + ); + render_caption( + frame.buffer_mut(), + staged_caption, + "STAGED", + theme, + !outline_focused && app.split_focus_role() == Role::Staged, + ); let (u_scroll, u_cursor) = app.pane_render_state(Role::Unstaged); let (s_scroll, s_cursor) = app.pane_render_state(Role::Staged); @@ -1852,11 +1919,16 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t } } -/// 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 pane width. 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 line = Line::from(vec![ + TSpan::styled("── ", rule_style), + TSpan::styled(label.to_string(), pane_header_label_style(theme, focused)), + TSpan::styled(" ──", rule_style), + ]); buf.set_line(area.x, area.y, &line, area.width); } @@ -2207,7 +2279,7 @@ 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; @@ -2217,7 +2289,7 @@ mod tests { use super::{hscroll_cut, pan_spans, render, STATUS_PLACEHOLDER}; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; - use crate::app::App; + use crate::app::{App, EffectiveZoom, Role}; use crate::keymap::Keymap; use crate::outline::OutlineItem; use crate::theme::Palette; @@ -2256,6 +2328,20 @@ mod tests { .collect() } + /// 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 @@ -3895,16 +3981,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), @@ -4063,18 +4140,10 @@ mod tests { "the summary panel's title now paints the diff pane's header row (y=0), got row \ {row} instead:\n{joined}" ); - // `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; + // `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), @@ -5490,4 +5559,312 @@ 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(); + + // "STAGED" is a substring of "UNSTAGED", so a naive `contains` search for the STAGED + // caption's row can false-positive onto the UNSTAGED caption's row (which also contains + // the literal text "STAGED") — same asymmetry + // `split_renders_both_role_captions_stacked_with_content_in_each_pane` guards against. + // Searching for "UNSTAGED" needs no such exclusion, since "UNSTAGED" never appears inside + // the STAGED-only row. + let 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")) + }; + + 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" + ); + } } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 1fb0674..6a63eaf 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -151,6 +151,7 @@ pub struct ThemeOverrides { pub cursor_bg: Option, pub selection_bg: Option, pub outline_cursor_unfocused_bg: Option, + pub pane_header_focused_fg: Option, } impl ThemeOverrides { @@ -277,6 +278,14 @@ pub struct Palette { /// Cursor wash for the outline pane while OPEN but NOT focused — dimmer than [`Palette::cursor_bg`]. pub outline_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 /// the terminal's own. @@ -352,6 +361,7 @@ impl Palette { cursor_bg: Color::Rgb(45, 50, 90), selection_bg: Color::Rgb(30, 66, 66), outline_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), @@ -413,6 +423,7 @@ impl Palette { 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), + pane_header_focused_fg: base.slot(5), background: base.slot(0), foreground: base.slot(5), dim: base.slot(3), @@ -459,6 +470,7 @@ impl Palette { // 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), // Semantic chrome also matches the terminal — probed base08/base0A/base0B, not the @@ -538,6 +550,7 @@ impl Palette { cursor_bg: cursor, selection_bg: selection, outline_cursor_unfocused_bg: outline_cursor_unfocused, + pane_header_focused_fg: Color::Reset, background: Color::Reset, foreground: Color::Reset, dim: Color::Reset, @@ -665,6 +678,9 @@ impl Palette { if let Some(color) = overrides.outline_cursor_unfocused_bg { self.outline_cursor_unfocused_bg = color; } + if let Some(color) = overrides.pane_header_focused_fg { + self.pane_header_focused_fg = color; + } } } @@ -753,6 +769,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 @@ -1173,6 +1201,19 @@ mod tests { } } + #[test] + fn mono_pane_header_focused_fg_collapses_with_dim_leaving_bold_the_only_differentiator() { + // CS1 (`focused-pane-header`, locked decision #3): under `NO_COLOR`, `pane_header_focused_fg` + // 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 From 0cf98026702f30618fd71f6b16a6b07161ebef8b Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 16 Jul 2026 16:40:22 -0400 Subject: [PATCH 153/203] feat(review): dim the cursorline wash in unfocused panes --- docs/adr/029-review-theming-base16-hybrid.md | 2 +- git-workon-review/src/app.rs | 11 +- git-workon-review/src/config.rs | 45 ++- git-workon-review/src/render.rs | 333 +++++++++++++++++-- git-workon-review/src/theme.rs | 101 +++--- 5 files changed, 407 insertions(+), 85 deletions(-) diff --git a/docs/adr/029-review-theming-base16-hybrid.md b/docs/adr/029-review-theming-base16-hybrid.md index 07dd20f..5b7081a 100644 --- a/docs/adr/029-review-theming-base16-hybrid.md +++ b/docs/adr/029-review-theming-base16-hybrid.md @@ -164,7 +164,7 @@ override keys**, not named bundled schemes. `workon.review.theme.*` (a subsectio | `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`, `outline-cursor-unfocused-bg`, `pane-header-focused-fg` | diff/cursor tint override (kebab-case, mirroring the `Palette` field names) | the matching field, verbatim | +| `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 diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 95beeea..5c67562 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -3693,8 +3693,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 { @@ -3705,7 +3710,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)) } } diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index e33f287..8b96c63 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -174,7 +174,7 @@ fn tint_slot<'a>(overrides: &'a mut ThemeOverrides, key: &str) -> Option<&'a mut "add-staged-strong" => &mut overrides.add_staged_strong, "cursor-bg" => &mut overrides.cursor_bg, "selection-bg" => &mut overrides.selection_bg, - "outline-cursor-unfocused-bg" => &mut overrides.outline_cursor_unfocused_bg, + "cursor-unfocused-bg" => &mut overrides.cursor_unfocused_bg, "pane-header-focused-fg" => &mut overrides.pane_header_focused_fg, _ => return None, }) @@ -628,6 +628,49 @@ mod tests { 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; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 6395720..85e839a 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -231,9 +231,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. @@ -860,7 +880,7 @@ fn render_outline_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palet /// 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 @@ -910,10 +930,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 }; @@ -1405,6 +1423,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, @@ -1413,12 +1432,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 { @@ -1777,12 +1797,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, ), } } @@ -1809,12 +1833,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; @@ -1845,28 +1872,35 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t app.clamp_scroll(); app.clamp_alt_scroll(); + // Each half's REAL focus (CS1, `unfocused-cursor-wash` — locked decisions #1/#5): the outline + // 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, - !outline_focused && app.split_focus_role() == Role::Unstaged, + u_focused, ); render_caption( frame.buffer_mut(), staged_caption, "STAGED", theme, - !outline_focused && app.split_focus_role() == Role::Staged, + 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( @@ -1879,6 +1913,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, @@ -1890,6 +1925,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 => { @@ -1903,6 +1939,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, @@ -1914,6 +1951,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t s_cursor, s_selection, theme, + s_focused, ); } } @@ -1933,8 +1971,12 @@ fn render_caption(buf: &mut Buffer, area: Rect, label: &str, theme: &Palette, fo } /// Render one SBS pane of `role`'s view for file `idx` into `area`, scrolled to `scroll`. The -/// cursor-row highlight draws 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, @@ -1946,6 +1988,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); @@ -2017,6 +2060,7 @@ fn render_pane_sbs( is_cursor, is_selected, theme, + focused, ); } DisplayRow::Row(row) => { @@ -2056,8 +2100,8 @@ fn render_pane_sbs( // 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 { ( @@ -2082,12 +2126,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), ); } } @@ -2190,6 +2237,7 @@ 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. @@ -2235,6 +2283,7 @@ fn render_pane_inline( is_cursor, is_selected, theme, + focused, ); } row => { @@ -2260,7 +2309,7 @@ fn render_pane_inline( ); // 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 { @@ -2328,6 +2377,34 @@ mod tests { .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 @@ -5867,4 +5944,210 @@ mod tests { "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" + ); + } } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 6a63eaf..302bd7d 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -150,7 +150,7 @@ pub struct ThemeOverrides { pub add_staged_strong: Option, pub cursor_bg: Option, pub selection_bg: Option, - pub outline_cursor_unfocused_bg: Option, + pub cursor_unfocused_bg: Option, pub pane_header_focused_fg: Option, } @@ -275,8 +275,11 @@ pub struct Palette { /// 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); @@ -360,7 +363,7 @@ impl Palette { add_staged_strong: Color::Rgb(34, 50, 38), 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), @@ -408,7 +411,7 @@ impl Palette { const STAGED_SUBTLE: f32 = 0.94; const STAGED_STRONG: f32 = 0.80; const CURSOR: f32 = 0.82; - const OUTLINE_CURSOR_UNFOCUSED: f32 = 0.90; + const CURSOR_UNFOCUSED: f32 = 0.90; Palette { syntax: SYNTAX_SLOTS.iter().map(|&s| base.slot(s)).collect(), @@ -422,7 +425,7 @@ impl Palette { add_staged_strong: tint_toward(green, base00, STAGED_STRONG), 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), @@ -465,7 +468,7 @@ impl Palette { add_staged_strong: curated.add_staged_strong, 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), @@ -506,36 +509,29 @@ impl Palette { /// falls to gutter glyph/structure instead, an accepted, documented degradation (see /// ADR-029's NO_COLOR note). pub fn mono(light: bool) -> Self { - // (subtle, strong, staged_subtle, staged_strong, cursor, selection, outline_cursor_unfocused) - let ( - subtle, - strong, - staged_subtle, - staged_strong, - cursor, - selection, - outline_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), - ) - }; + // (subtle, strong, staged_subtle, staged_strong, cursor, selection, cursor_unfocused) + let (subtle, strong, staged_subtle, staged_strong, 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()], @@ -549,7 +545,7 @@ impl Palette { add_staged_strong: staged_strong, cursor_bg: cursor, selection_bg: selection, - outline_cursor_unfocused_bg: outline_cursor_unfocused, + cursor_unfocused_bg: cursor_unfocused, pane_header_focused_fg: Color::Reset, background: Color::Reset, foreground: Color::Reset, @@ -675,8 +671,8 @@ impl Palette { if let Some(color) = overrides.selection_bg { self.selection_bg = color; } - if let Some(color) = overrides.outline_cursor_unfocused_bg { - self.outline_cursor_unfocused_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; @@ -722,7 +718,7 @@ mod tests { assert_eq!(t.add_staged_strong, 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] @@ -852,13 +848,11 @@ mod tests { } #[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] @@ -937,10 +931,7 @@ mod tests { 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] @@ -1158,7 +1149,7 @@ mod tests { t.add_staged_strong, t.cursor_bg, t.selection_bg, - t.outline_cursor_unfocused_bg, + t.cursor_unfocused_bg, ] { assert_achromatic(wash); } @@ -1188,15 +1179,15 @@ mod tests { assert!(staged_strong < strong, "staged should sit closer to black"); } - // Cursor vs selection are distinct, and the unfocused outline cursor reads dimmer + // 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 (outline_unfocused, _, _) = rgb(t.outline_cursor_unfocused_bg); + let (cursor_unfocused, _, _) = rgb(t.cursor_unfocused_bg); if light { - assert!(outline_unfocused > cursor); + assert!(cursor_unfocused > cursor); } else { - assert!(outline_unfocused < cursor); + assert!(cursor_unfocused < cursor); } } } From 83a4e88104cfc4125584a4d521734c62e1f53d54 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 17 Jul 2026 15:05:03 -0400 Subject: [PATCH 154/203] feat(review): dim header counters with their pane's focus --- git-workon-review/src/render.rs | 232 +++++++++++++++++++++++++++----- 1 file changed, 201 insertions(+), 31 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 85e839a..482da93 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -784,15 +784,21 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect frame.render_widget(Paragraph::new(lines).block(block), popup_area); } -/// The style for a pane header/caption LABEL word (CS1, `focused-pane-header`) — never the -/// surrounding chrome (counters, diffstats, rule characters, markers), which keep their own -/// existing colors regardless of focus (locked decision #4). `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 call -/// site across a frame's outline header / diff header / split captions should ever pass `true` -/// (the exactly-one-lit-label invariant — see the module's `focused-pane-header` handoff). +/// 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() @@ -804,7 +810,8 @@ fn pane_header_label_style(theme: &Palette, focused: bool) -> Style { } /// The outline pane's own top row (CS1, `pane-headers`): `[i/n] {display_label}` (the active -/// changeset's TRUE stack position, the display label styled via [`pane_header_label_style`] — +/// 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 @@ -831,9 +838,7 @@ fn render_outline_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palet let mut spans = vec![ TSpan::styled( format!("[{i}/{n}] "), - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), + pane_header_label_style(theme, focused), ), TSpan::styled(title, pane_header_label_style(theme, focused)), ]; @@ -1237,12 +1242,20 @@ fn hscroll_indicator_span(app: &App, theme: &Palette) -> Option> /// 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}` -/// bold, 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`]. -fn changeset_prefix_spans(app: &App, theme: &Palette, icons: IconMode) -> Vec> { +/// 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(); @@ -1250,9 +1263,7 @@ fn changeset_prefix_spans(app: &App, theme: &Palette, icons: IconMode) -> Vec Vec]| { + 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" + ); + } } From caf6105e14dcdae9eaf975c1365417403397b82c Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 17 Jul 2026 18:35:36 -0400 Subject: [PATCH 155/203] feat(review): run split caption rules full width as pane divider --- git-workon-review/src/render.rs | 55 ++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 482da93..91d3866 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -1968,15 +1968,20 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t } } -/// Write a split pane's role caption (`── LABEL ──`) across the pane width. 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`. +/// 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(" ──", rule_style), + TSpan::styled(format!(" {}", "─".repeat(fill)), rule_style), ]); buf.set_line(area.x, area.y, &line, area.width); } @@ -2934,6 +2939,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 = content + .iter() + .position(|line| { + line.contains(label) && (label != "STAGED" || !line.contains("UNSTAGED")) + }) + .expect("caption present"); + 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 From 390a29045aee2effcfce7415e9b3913d12f83c2d Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 23 Jul 2026 12:21:21 -0400 Subject: [PATCH 156/203] fix(review): dedupe caption_row helper across split-caption tests --- git-workon-review/src/render.rs | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 91d3866..55e57eb 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -2961,12 +2961,7 @@ mod tests { let content = buf_lines(&buf); for label in ["UNSTAGED", "STAGED"] { - let cap = content - .iter() - .position(|line| { - line.contains(label) && (label != "STAGED" || !line.contains("UNSTAGED")) - }) - .expect("caption present"); + let cap = caption_row(&content, label); let row = content[cap].trim_end(); assert_eq!( row.chars().count(), @@ -5836,21 +5831,6 @@ mod tests { let theme = Palette::dark(); - // "STAGED" is a substring of "UNSTAGED", so a naive `contains` search for the STAGED - // caption's row can false-positive onto the UNSTAGED caption's row (which also contains - // the literal text "STAGED") — same asymmetry - // `split_renders_both_role_captions_stacked_with_content_in_each_pane` guards against. - // Searching for "UNSTAGED" needs no such exclusion, since "UNSTAGED" never appears inside - // the STAGED-only row. - let 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")) - }; - 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); From fd74ab6d4ecf115b4c67a808eced0fb8437830d7 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 17 Jul 2026 18:48:15 -0400 Subject: [PATCH 157/203] fix(review): render one-sided patch headers git apply accepts --- git-workon-review/src/synthesis.rs | 173 ++++++++++++++++++++-- git-workon-review/tests/suite/file_ops.rs | 159 ++++++++++++++------ 2 files changed, 277 insertions(+), 55 deletions(-) diff --git a/git-workon-review/src/synthesis.rs b/git-workon-review/src/synthesis.rs index a0f8934..694c300 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(), @@ -760,6 +787,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 { diff --git a/git-workon-review/tests/suite/file_ops.rs b/git-workon-review/tests/suite/file_ops.rs index 152c5a8..cb78e8a 100644 --- a/git-workon-review/tests/suite/file_ops.rs +++ b/git-workon-review/tests/suite/file_ops.rs @@ -45,35 +45,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 +76,134 @@ 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() { + use std::io::Write; + + 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 patch = naive_untracked_hunk_patch("new.txt", "hello\n"); - let result = CliApplier.apply( - repo, - &patch, - ApplyDestination::Index, - ApplyDirection::Forward, - ); + let workdir = repo.workdir().expect("workdir"); + + 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"); + let output = child.wait_with_output().expect("wait for git apply"); 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). + { + use std::io::Write; + + 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 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"); + let output = child.wait_with_output().expect("wait for git apply"); + 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() From e3f996aa360a29ae1e35ed14d81b2023d6b9d360 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 17 Jul 2026 19:12:06 -0400 Subject: [PATCH 158/203] feat(review): line stage and discard on untracked and added files --- git-workon-fixture/src/fixture_builder.rs | 37 +- git-workon-review/src/app.rs | 183 ++++++++- git-workon-review/src/ops.rs | 32 +- git-workon-review/src/synthesis.rs | 224 +++++++++-- git-workon-review/tests/suite/file_ops.rs | 92 ++++- .../tests/suite/roundtrip_corpus.rs | 362 +++++++++++++++++- 6 files changed, 848 insertions(+), 82 deletions(-) diff --git a/git-workon-fixture/src/fixture_builder.rs b/git-workon-fixture/src/fixture_builder.rs index 3b6e5a7..aa769cc 100644 --- a/git-workon-fixture/src/fixture_builder.rs +++ b/git-workon-fixture/src/fixture_builder.rs @@ -148,6 +148,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> { @@ -172,6 +173,7 @@ impl<'fixture> FixtureBuilder<'fixture> { partially_staged_files: Vec::new(), untracked_symlinks: Vec::new(), executable_unstaged_files: Vec::new(), + executable_untracked_files: Vec::new(), } } @@ -369,6 +371,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 @@ -551,11 +567,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(), ); @@ -818,6 +836,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 5c67562..3030b8e 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -4531,11 +4531,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(); @@ -4548,9 +4548,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; @@ -4568,9 +4568,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(); @@ -4584,11 +4588,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(); @@ -4596,6 +4598,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()) @@ -4616,6 +4628,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 @@ -8064,8 +8121,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") @@ -8074,23 +8131,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; diff --git a/git-workon-review/src/ops.rs b/git-workon-review/src/ops.rs index 3f22632..fbb536a 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/synthesis.rs b/git-workon-review/src/synthesis.rs index 694c300..936bdc5 100644 --- a/git-workon-review/src/synthesis.rs +++ b/git-workon-review/src/synthesis.rs @@ -236,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, @@ -255,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(), @@ -483,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 @@ -597,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, @@ -939,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, @@ -1151,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, @@ -1175,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/tests/suite/file_ops.rs b/git-workon-review/tests/suite/file_ops.rs index cb78e8a..c736fe7 100644 --- a/git-workon-review/tests/suite/file_ops.rs +++ b/git-workon-review/tests/suite/file_ops.rs @@ -229,54 +229,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] @@ -479,3 +506,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/suite/roundtrip_corpus.rs b/git-workon-review/tests/suite/roundtrip_corpus.rs index cf6df13..0e28924 100644 --- a/git-workon-review/tests/suite/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")); } From 647c4d4a7c1e7f00193dbe6b1e8184cb322952e1 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 23 Jul 2026 12:24:23 -0400 Subject: [PATCH 159/203] fix(review): extract shared git_apply_cached test helper --- git-workon-review/tests/suite/file_ops.rs | 58 ++++++++++------------- 1 file changed, 24 insertions(+), 34 deletions(-) diff --git a/git-workon-review/tests/suite/file_ops.rs b/git-workon-review/tests/suite/file_ops.rs index c736fe7..bc1aab4 100644 --- a/git-workon-review/tests/suite/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 @@ -86,8 +108,6 @@ fn naive_hunk_stage_of_deletion_stages_empty_blob() { /// `creation_patch_with_proper_headers_is_accepted_by_both_appliers` below for the fixed one). #[test] fn naive_hunk_stage_of_untracked_errors() { - use std::io::Write; - let raw: &[u8] = b"diff --git a/new.txt b/new.txt\n\ index 0000000..0000000 100644\n\ --- /dev/null\n\ @@ -103,21 +123,7 @@ index 0000000..0000000 100644\n\ let repo = fixture.repo().expect("repo"); let workdir = repo.workdir().expect("workdir"); - 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"); - let output = child.wait_with_output().expect("wait for git apply"); + let output = git_apply_cached(workdir, raw); assert!( !output.status.success(), @@ -166,8 +172,6 @@ index 0000000..0000000\n\ // `git apply --cached` directly (CliApplier's mechanism, bypassing PatchText). { - use std::io::Write; - let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .untracked_file("new.txt", "hello\nworld\n") @@ -176,21 +180,7 @@ index 0000000..0000000\n\ let repo = fixture.repo().expect("repo"); let workdir = repo.workdir().expect("workdir"); - 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"); - let output = child.wait_with_output().expect("wait for git apply"); + let output = git_apply_cached(workdir, raw); assert!( output.status.success(), "git apply --cached rejected the proper creation header: {}", From ba7d2c9c8bd807ab44bab38a443ecfa44e51effb Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sun, 19 Jul 2026 21:01:25 -0400 Subject: [PATCH 160/203] test(review): pin canvas paint under split-view content rows --- git-workon-review/src/render.rs | 35 +++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 55e57eb..79bc774 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -5630,6 +5630,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() From 3643d3828b02403f13ed5986468c7f628edcd461 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 20 Jul 2026 12:54:01 -0400 Subject: [PATCH 161/203] fix(review): key silent-probe cache to the concrete tty device --- git-workon-review/src/probe_cache.rs | 30 ++++++++++------ git-workon-review/tests/pty/pty_smoke.rs | 35 ++++++++++++++++++- .../tests/pty/pty_support/mod.rs | 17 +++++++-- 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/git-workon-review/src/probe_cache.rs b/git-workon-review/src/probe_cache.rs index 58e24d8..467638f 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/tests/pty/pty_smoke.rs b/git-workon-review/tests/pty/pty_smoke.rs index 3b3e9ef..197db8b 100644 --- a/git-workon-review/tests/pty/pty_smoke.rs +++ b/git-workon-review/tests/pty/pty_smoke.rs @@ -20,7 +20,7 @@ //! Color/SGR assertions are deliberately absent — capturing ratatui frames through a PTY is //! unreliable; reply *parsing* is unit-tested in `terminal_query.rs`. -use crate::pty_support::spawn_review; +use crate::pty_support::{probe_cache_path, spawn_review}; use std::io::Write; use std::time::{Duration, Instant}; @@ -122,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 @@ -136,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/pty_support/mod.rs b/git-workon-review/tests/pty/pty_support/mod.rs index a38434a..d311f97 100644 --- a/git-workon-review/tests/pty/pty_support/mod.rs +++ b/git-workon-review/tests/pty/pty_support/mod.rs @@ -28,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) @@ -45,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() +} From 4b36c97a428b7a650bb7da34913c125f4d61d0e7 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 20 Jul 2026 15:26:18 -0400 Subject: [PATCH 162/203] feat(review): derive auto diff washes from probed terminal accents --- docs/adr/029-review-theming-base16-hybrid.md | 18 +++ git-workon-review/src/terminal_query.rs | 14 ++- git-workon-review/src/theme.rs | 117 ++++++++++++++----- 3 files changed, 118 insertions(+), 31 deletions(-) diff --git a/docs/adr/029-review-theming-base16-hybrid.md b/docs/adr/029-review-theming-base16-hybrid.md index 5b7081a..a84c0eb 100644 --- a/docs/adr/029-review-theming-base16-hybrid.md +++ b/docs/adr/029-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 diff --git a/git-workon-review/src/terminal_query.rs b/git-workon-review/src/terminal_query.rs index 174dbff..945bde4 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-029). +//! 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-029 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_subtle, Palette::dark().del_subtle); + // A dark probed bg still borrows dark's curated cursor wash. + assert_eq!(palette.cursor_bg, Palette::dark().cursor_bg); } #[test] diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 302bd7d..b24651a 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 — @@ -443,29 +444,49 @@ impl Palette { /// A scheme derived from the terminal's own colors (ADR-029'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-029: 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-029 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 + // (subtle, strong, staged_subtle, staged_strong): how far each wash blends from the + // accent toward the probed background. Light reuses `Palette::light`'s tuned ratios. + let (subtle, strong, staged_subtle, staged_strong) = if light { + (0.88, 0.65, 0.94, 0.80) + } else { + (0.90, 0.75, 0.94, 0.85) + }; 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_subtle: tint_toward(red, background, subtle), + del_strong: tint_toward(red, background, strong), + add_subtle: tint_toward(green, background, subtle), + add_strong: tint_toward(green, background, strong), + del_staged_subtle: tint_toward(red, background, staged_subtle), + del_staged_strong: tint_toward(red, background, staged_strong), + add_staged_subtle: tint_toward(green, background, staged_subtle), + add_staged_strong: tint_toward(green, background, staged_strong), cursor_bg: curated.cursor_bg, selection_bg: curated.selection_bg, cursor_unfocused_bg: curated.cursor_unfocused_bg, @@ -924,26 +945,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-029 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_subtle, tint_toward(red, bg, 0.90)); + assert_eq!(palette.del_strong, tint_toward(red, bg, 0.75)); + assert_eq!(palette.add_subtle, tint_toward(green, bg, 0.90)); + assert_eq!(palette.add_strong, tint_toward(green, bg, 0.75)); + assert_ne!(palette.del_subtle, Palette::dark().del_subtle); + } + + #[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_subtle, tint_toward(red, bg, 0.94)); + assert_eq!(palette.del_staged_strong, tint_toward(red, bg, 0.85)); + assert_eq!(palette.add_staged_subtle, tint_toward(green, bg, 0.94)); + assert_eq!(palette.add_staged_strong, tint_toward(green, bg, 0.85)); + assert_ne!(palette.del_staged_subtle, palette.del_subtle); + assert_ne!(palette.add_staged_strong, palette.add_strong); + } + + #[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_subtle, tint_toward(probed.slot(8), bg, 0.88)); + assert_eq!(palette.add_strong, 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.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] @@ -963,8 +1027,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)); From fda3c2d88061fb0f8e799c40140b8b8d9667704d Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 20 Jul 2026 15:44:22 -0400 Subject: [PATCH 163/203] feat(review): render comment captures in italics --- git-workon-review/src/render.rs | 55 ++++++++++++++++++++++++++++----- git-workon-review/src/theme.rs | 29 +++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 79bc774..c305369 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -285,12 +285,14 @@ 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 @@ -333,11 +335,22 @@ fn compose_segments( .rev() .find(|(s, e, _)| mid >= *s && mid < *e) .map(|(_, _, c)| *c); - let fg = fg_spans + let (fg, italic) = 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 }); + .map(|s| { + ( + theme.syntax(s.capture), + crate::theme::syntax_italic(s.capture), + ) + }) + .unwrap_or((theme.foreground, false)); + segments.push(Segment { + start, + end, + bg, + fg, + italic, + }); } segments } @@ -582,6 +595,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) @@ -2352,12 +2368,13 @@ mod tests { use unicode_width::UnicodeWidthChar; use super::{ - changeset_prefix_spans, hscroll_cut, pan_spans, pane_header_label_style, render, - STATUS_PLACEHOLDER, + changeset_prefix_spans, compose_segments, hscroll_cut, pan_spans, pane_header_label_style, + render, STATUS_PLACEHOLDER, }; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; use crate::app::{App, EffectiveZoom, Role}; + use crate::highlight::FgSpan; use crate::keymap::Keymap; use crate::outline::OutlineItem; use crate::theme::Palette; @@ -2376,6 +2393,30 @@ mod tests { 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"); + } + /// 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 { diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index b24651a..878c591 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -246,6 +246,25 @@ 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-029's theme-controlled half). /// /// Syntax foreground is looked up per capture index via [`Palette::syntax`]; the diff-background @@ -925,6 +944,16 @@ mod tests { Base16 { slots } } + #[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 From d9f6b397625ef02c9a97fe147851bc5fceb4406b Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 20 Jul 2026 19:08:33 -0400 Subject: [PATCH 164/203] feat(review): screen the filler hatch back to its own base01 fg --- git-workon-review/src/config.rs | 21 ++++++++++++++++++ git-workon-review/src/render.rs | 2 +- git-workon-review/src/theme.rs | 38 +++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index 8b96c63..c82c445 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -176,6 +176,7 @@ fn tint_slot<'a>(overrides: &'a mut ThemeOverrides, key: &str) -> Option<&'a mut "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, }) } @@ -694,6 +695,26 @@ mod tests { 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; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index c305369..efc45ce 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -621,7 +621,7 @@ fn build_pane_line( 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 { diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 878c591..ee118b8 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -153,6 +153,7 @@ pub struct ThemeOverrides { pub selection_bg: Option, pub cursor_unfocused_bg: Option, pub pane_header_focused_fg: Option, + pub filler_fg: Option, } impl ThemeOverrides { @@ -320,6 +321,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-029's hybrid boundary — see this @@ -389,6 +398,7 @@ impl Palette { 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), @@ -451,6 +461,7 @@ impl Palette { 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, @@ -516,6 +527,7 @@ impl Palette { 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), @@ -591,6 +603,7 @@ impl Palette { 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, @@ -717,6 +730,9 @@ impl Palette { 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; + } } } @@ -944,6 +960,28 @@ 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 From 0c689e22336254930154c9b230dc17ac2d6534bc Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 22 Jul 2026 16:13:00 -0400 Subject: [PATCH 165/203] refactor(review): unify startup config resolution into resolve_runtime --- git-workon-review/src/config.rs | 161 +++++++++++++++++++++++++++++++- git-workon-review/src/main.rs | 84 ++++++++--------- git-workon-review/src/theme.rs | 19 ++++ 3 files changed, 217 insertions(+), 47 deletions(-) diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index c82c445..4b2b2ac 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -57,7 +57,8 @@ use git2::Repository; use ratatui::style::Color; -use crate::theme::{self, ThemeOverrides}; +use crate::keymap::Keymap; +use crate::theme::{self, Palette, PaletteContext, ThemeOverrides}; /// Which view a keybinding or view-setting applies to. /// @@ -127,6 +128,66 @@ pub struct RawViewConfig { pub diff_zoom: Option, } +/// Everything `main.rs`'s startup resolution ladder reads out of `workon.review.*`, resolved in +/// one call so a config reload (see the `reload-config` handoff) 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 only — 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 — see the handoff's "Structural core" section. +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 warnings = match config.theme_overrides() { + Ok((overrides, warnings)) => { + palette.apply_overrides(&overrides); + warnings + } + Err(_) => Vec::new(), + }; + + 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 /// [`git2::ConfigEntry::name`]) into its (view, action) components, per ADR-028's grammar: /// bare `workon.review.bind.` is the global keymap; `workon.review..bind.` @@ -770,6 +831,104 @@ mod tests { 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 diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 198f34a..88e860f 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -93,65 +93,57 @@ fn main() -> Result<()> { return Ok(()); } - // Resolve the keymap from git config once at startup, BEFORE `repo` moves into `App` - // (ADR-028). 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` stay CS5's I/O-free `for_theme` path — but that ladder now lives in + // `resolve_runtime` below; here we only need `auto_base` (what to cache for `PaletteContext`) + // and `probed`. `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 (mut 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), }; - // CS1 (user-configurable colors tier): apply any `workon.review.theme.*` slot/tint - // overrides on top of whichever base was just resolved above — works uniformly on - // `dark`/`light`/`auto`'s probe result (see `Palette::apply_overrides`'s doc comment). A - // config-read error degrades to no overrides, same posture as every other getter here; a - // malformed value or unknown key is collected as a warning and joined into the startup - // notice in `seat_app` below, alongside the keymap/view-config warnings. - let theme_override_warnings = match ReviewConfig::new(&repo).theme_overrides() { - Ok((overrides, warnings)) => { - theme.apply_overrides(&overrides); - warnings - } - Err(_) => Vec::new(), - }; - - // CS2 (`no-color-mono`): `NO_COLOR` is an env kill-switch — it wins over any override - // applied just above, so it must be checked last, after resolution AND overrides. The - // ladder choice (`is_light_background`) reads the pre-mono `theme.background` so `auto`'s - // probe still picks the right dark/pale ladder. `FORCE_COLOR` is deliberately not consulted - // (see `no_color`'s doc comment). - if no_color(std::env::var_os("NO_COLOR").as_deref()) { - theme = Palette::mono(theme::is_light_background(theme.background)); + // 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. + // 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); } - // 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(); + // `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-028) 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-028); 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 diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index ee118b8..4ca9922 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -272,6 +272,7 @@ pub fn syntax_italic(capture: usize) -> bool { /// 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`]). @@ -369,6 +370,24 @@ pub struct Palette { 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 { /// The curated dark scheme: base16-eighties.dark accents + the M3–M5 hand-tuned diff/cursor /// tints, reproduced byte-for-byte (the pixel-identity gate — see the module doc and ADR-029). From 84535b2f6dcb9629237b6b0dbcc7ea4014321f31 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 22 Jul 2026 16:20:50 -0400 Subject: [PATCH 166/203] feat(review): add reload-config command for live theme/keymap reload --- .../028-review-git-native-config-schema.md | 10 ++ git-workon-review/src/app.rs | 163 +++++++++++++++++- git-workon-review/src/config.rs | 11 ++ git-workon-review/src/keymap.rs | 48 ++++++ git-workon-review/src/main.rs | 4 +- git-workon-review/src/tui.rs | 91 ++++++++-- 6 files changed, 314 insertions(+), 13 deletions(-) diff --git a/docs/adr/028-review-git-native-config-schema.md b/docs/adr/028-review-git-native-config-schema.md index 065f8a2..a7f5222 100644 --- a/docs/adr/028-review-git-native-config-schema.md +++ b/docs/adr/028-review-git-native-config-schema.md @@ -75,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-029) 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 diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 3030b8e..29a95c2 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1353,6 +1353,12 @@ pub struct App { /// 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`] @@ -1534,6 +1540,7 @@ impl App { 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 @@ -2267,6 +2274,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-031'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: @@ -4019,6 +4047,58 @@ impl App { 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 + } + /// Set a transient footer notice (see [`Self::notice`]'s doc comment). Overwrites any /// currently-showing notice rather than queuing — only one message is ever on screen. pub fn notify(&mut self, text: impl Into, severity: Severity) { @@ -5105,7 +5185,7 @@ mod tests { SummaryTarget, Zoom, DEFAULT_OUTLINE_WIDTH, HSCROLL_STEP, 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}; @@ -10282,6 +10362,87 @@ mod tests { assert!(warnings[0].contains("diff.zoom")); } + // ── `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 diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index 4b2b2ac..4d9c7fb 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -33,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 @@ -44,6 +45,16 @@ //! zoom = combined //! ``` //! +//! ## 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 diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 1943bd3..ec4da2d 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -43,6 +43,7 @@ pub enum Command { Quit, ToggleOutline, ToggleHelp, + ReloadConfig, // Diff view. CursorDown, CursorUp, @@ -133,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, @@ -1367,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 { diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 88e860f..56f488b 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -222,7 +222,7 @@ fn main() -> Result<()> { // A carried acquire failure surfaces HERE — the same logical point (running the TUI) it // surfaced at before CS5 moved the terminal takeover ahead of the diff phase. 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-031's "Slots") — `App` is constructible from @@ -245,7 +245,7 @@ fn main() -> Result<()> { ); 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()?; } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index a211822..e50ba5f 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, @@ -480,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), @@ -616,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(), @@ -1040,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-031'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()); @@ -1056,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) } @@ -1074,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()); @@ -1099,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) } @@ -1152,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 { @@ -1220,6 +1246,29 @@ 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 warnings = keymap.warnings().to_vec(); + warnings.extend(runtime.warnings); + warnings.extend(view_warnings); + if warnings.is_empty() { + app.notify("config reloaded", Severity::Info); + } else { + app.notify(warnings.join("; "), Severity::Error); + } + } } } @@ -3394,6 +3443,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 From 2cfb2fce0c89ba64ed1da2d8e3f8c1dfe14feeed Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 22 Jul 2026 16:23:15 -0400 Subject: [PATCH 167/203] fix(review): re-plumb the zoom key hint on config reload --- git-workon-review/src/config.rs | 7 ++++--- git-workon-review/src/tui.rs | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index 4d9c7fb..adbbc91 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -140,8 +140,9 @@ pub struct RawViewConfig { } /// Everything `main.rs`'s startup resolution ladder reads out of `workon.review.*`, resolved in -/// one call so a config reload (see the `reload-config` handoff) reproduces startup's resolution -/// exactly rather than duplicating it — guaranteed drift otherwise. `view_config` is left raw +/// one call so a config reload (the `reload-config` action, see +/// [ADR-028](../../../docs/adr/028-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 { @@ -164,7 +165,7 @@ pub struct RuntimeConfig { /// 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 — see the handoff's "Structural core" section. +/// two can never drift apart. pub fn resolve_runtime(repo: &Repository, ctx: &PaletteContext) -> RuntimeConfig { let config = ReviewConfig::new(repo); diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index e50ba5f..b12a6c7 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -1259,6 +1259,12 @@ fn event_loop( // A half-entered chord against the OLD keymap is meaningless once the bindings under // it have changed. pending.clear(); + // Re-plumb 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. + if let Some(label) = workon_review::keymap::primary_key(keymap, Command::CycleZoom) { + app.set_zoom_key_label(label); + } let view_warnings = app.reload_view_config(&runtime.view_config); let mut warnings = keymap.warnings().to_vec(); warnings.extend(runtime.warnings); From c72dad2714d7796a92dd932bd0fe9c90ff794ffc Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 23 Jul 2026 12:27:45 -0400 Subject: [PATCH 168/203] fix(review): share seat-time zoom hint and warning plumbing with reload --- git-workon-review/src/app.rs | 5 ++-- git-workon-review/src/main.rs | 44 +++++++++++++++++++++++++---------- git-workon-review/src/tui.rs | 20 +++++++--------- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 29a95c2..ee2e713 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1560,8 +1560,9 @@ impl App { } /// 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::seat_app` calls this with the - /// resolved [`crate::keymap::Command::CycleZoom`] binding right after construction. + /// 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; } diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 56f488b..402d0c2 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -270,12 +270,6 @@ fn seat_app( if let Some(source) = source { app.set_review_source(source); } - // Plumb the resolved CycleZoom binding into the "cycle zoom" refusal hint (App has no keymap - // field of its own — see `App::zoom_key_label`'s doc comment); leaves the "Z" default in - // place if the command has no bound key. - if let Some(label) = keymap::primary_key(keymap, Command::CycleZoom) { - app.set_zoom_key_label(label); - } // CS4: defer file loads to the event loop's input-idle window rather than blocking here (or // on any later selection change) — `app.open_current()` below marks the initial open pending // instead of loading eagerly; see `tui::run`'s doc comment for the resulting startup @@ -291,15 +285,41 @@ fn seat_app( // A misconfigured keybinding, view-config setting, or theme override is non-fatal: show the // collected warnings as a startup notice (cleared on the first keypress, like any notice) and - // run with the defaults for those keys/settings/colors. + // 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); - warnings.extend(theme_override_warnings.iter().cloned()); - if !warnings.is_empty() { + warnings.extend(extra_warnings); + let had_warnings = !warnings.is_empty(); + if had_warnings { app.notify(warnings.join("; "), Severity::Error); } - - app + had_warnings } #[cfg(test)] diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index b12a6c7..81efab3 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -1259,20 +1259,16 @@ fn event_loop( // A half-entered chord against the OLD keymap is meaningless once the bindings under // it have changed. pending.clear(); - // Re-plumb 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. - if let Some(label) = workon_review::keymap::primary_key(keymap, Command::CycleZoom) { - app.set_zoom_key_label(label); - } let view_warnings = app.reload_view_config(&runtime.view_config); - let mut warnings = keymap.warnings().to_vec(); - warnings.extend(runtime.warnings); - warnings.extend(view_warnings); - if warnings.is_empty() { + 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); - } else { - app.notify(warnings.join("; "), Severity::Error); } } } From 525151af9bb77552d2748ca01559a559a9b6e850 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 23 Jul 2026 12:29:51 -0400 Subject: [PATCH 169/203] fix(review): fold the theme fallback ladder into one shared path --- git-workon-review/src/main.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 402d0c2..5a70d3d 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -97,18 +97,18 @@ fn main() -> Result<()> { // 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 — but that ladder now lives in - // `resolve_runtime` below; here we only need `auto_base` (what to cache for `PaletteContext`) - // and `probed`. `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. + // 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 (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), }; // CS2 (`no-color-mono`): read the env kill-switch once here — `resolve_runtime` applies it From 5397efd92fac557a5b4c271c2f9f2ad5d615c4c8 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 22 Jul 2026 17:49:57 -0400 Subject: [PATCH 170/203] docs(review): record the diff line/edit fg-bg split decision --- CONTEXT.md | 14 +++ docs/adr/029-review-theming-base16-hybrid.md | 89 ++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/CONTEXT.md b/CONTEXT.md index 9c4593d..c02ef15 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/docs/adr/029-review-theming-base16-hybrid.md b/docs/adr/029-review-theming-base16-hybrid.md index a84c0eb..3a367c2 100644 --- a/docs/adr/029-review-theming-base16-hybrid.md +++ b/docs/adr/029-review-theming-base16-hybrid.md @@ -225,6 +225,95 @@ a palette-EXTERNAL color source `mono()`'s own `Color::Reset` fields can't reach carries a `colorless` flag (`false` on every curated/probed constructor, `true` only on `mono`) and `render.rs`'s icon paint sites collapse to `foreground` themselves whenever it's set. +## Revised (CS11, diff foreground/background split) + +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-028](028-review-git-native-config-schema.md) — `workon.review.theme` config key From 4bd0efae9a9c17b8d15e3caa85742ff6c480f963 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 22 Jul 2026 18:06:55 -0400 Subject: [PATCH 171/203] feat(review): split diff wash fg from bg, rename line/edit --- git-workon-review/src/config.rs | 20 +- git-workon-review/src/render.rs | 171 +++--- git-workon-review/src/terminal_query.rs | 14 +- git-workon-review/src/theme.rs | 710 +++++++++++++++++------- 4 files changed, 626 insertions(+), 289 deletions(-) diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index adbbc91..a656a63 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -237,14 +237,18 @@ fn slot_index(key: &str) -> Option { /// `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-subtle" => &mut overrides.del_subtle, - "del-strong" => &mut overrides.del_strong, - "add-subtle" => &mut overrides.add_subtle, - "add-strong" => &mut overrides.add_strong, - "del-staged-subtle" => &mut overrides.del_staged_subtle, - "del-staged-strong" => &mut overrides.del_staged_strong, - "add-staged-subtle" => &mut overrides.add_staged_subtle, - "add-staged-strong" => &mut overrides.add_staged_strong, + "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, diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index efc45ce..c35e35d 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -123,14 +123,14 @@ fn diffstat_spans( TSpan::styled( format!("{added_prefix}{adds}"), Style::default() - .fg(theme.add_strong) + .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_strong) + .fg(theme.del_fg) .add_modifier(Modifier::BOLD), ), ] @@ -326,10 +326,10 @@ 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() @@ -359,7 +359,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)] @@ -405,35 +405,35 @@ 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. +/// 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 bright = (theme.del_subtle, theme.del_strong); - let staged = (theme.del_staged_subtle, theme.del_staged_strong); + let unstaged = (theme.del_line_bg, theme.del_edit_bg); + let staged = (theme.del_staged_line_bg, theme.del_staged_edit_bg); match mode { - AttributionMode::Plain => bright, + AttributionMode::Plain => unstaged, AttributionMode::StagedUniform => staged, AttributionMode::Attributed(attribution) => { if attribution.del_is_staged(old_lnum) { staged } else { - bright + unstaged } } } } -/// The (subtle, strong) background pair for an Add cell at `new_lnum`, given `mode`, resolved from -/// `theme`'s bright vs. staged Add tints. +/// 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 bright = (theme.add_subtle, theme.add_strong); - let staged = (theme.add_staged_subtle, theme.add_staged_strong); + let unstaged = (theme.add_line_bg, theme.add_edit_bg); + let staged = (theme.add_staged_line_bg, theme.add_staged_edit_bg); match mode { - AttributionMode::Plain => bright, + AttributionMode::Plain => unstaged, AttributionMode::StagedUniform => staged, AttributionMode::Attributed(attribution) => { if attribution.add_is_unstaged(new_lnum) { - bright + unstaged } else { staged } @@ -552,9 +552,10 @@ fn pan_spans(spans: Vec>, cols: usize, theme: &Palette) -> Vec Vec> { let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); - if let Some((subtle_bg, strong_bg)) = emphasis { + if let Some((line_bg, edit_bg)) = 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-029's CS11 section: "edit" is the + // domain term precisely because this branch would falsify "word"). + bg_spans.push((0, text.len(), edit_bg)); } } @@ -991,15 +994,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, } @@ -1019,8 +1022,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-029's CS11 section.) fn outline_status_spans( status: crate::outline::StagedStatus, change: FileStatus, @@ -1051,12 +1056,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)), @@ -1514,23 +1515,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)), ])); } @@ -1586,12 +1581,12 @@ 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), ), ])); } @@ -2517,8 +2512,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")) @@ -2538,22 +2533,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" ); } @@ -2896,7 +2891,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"; @@ -2923,7 +2918,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" ); } @@ -3093,7 +3088,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 @@ -3108,8 +3103,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:?}" @@ -3137,8 +3132,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:?}" @@ -3748,8 +3743,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, \ @@ -4898,9 +4893,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") @@ -4926,16 +4921,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") @@ -4955,8 +4950,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, @@ -4968,7 +4963,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") @@ -4988,13 +4983,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" ); } @@ -5102,7 +5097,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() @@ -5172,8 +5167,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 @@ -5190,8 +5185,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" ); } @@ -6362,7 +6357,7 @@ mod tests { ); assert_eq!( unfocused_add, - (Some(theme.add_strong), true), + (Some(theme.add_fg), true), "the diffstat span keeps its own semantic color and bold regardless of focus" ); } diff --git a/git-workon-review/src/terminal_query.rs b/git-workon-review/src/terminal_query.rs index 945bde4..6e4068f 100644 --- a/git-workon-review/src/terminal_query.rs +++ b/git-workon-review/src/terminal_query.rs @@ -792,7 +792,7 @@ mod tests { assert_eq!(palette.syntax(keyword), expected.slots[14]); // 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_subtle, Palette::dark().del_subtle); + 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); } @@ -806,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. @@ -817,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 ); } @@ -826,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 4ca9922..49c4ccf 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -42,7 +42,7 @@ //! //! **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-subtle`, `cursor-bg`, …) git-config keys under +//! (`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 — @@ -136,19 +136,29 @@ pub(crate) fn parse_hex_color(s: &str) -> Option { /// Per-slot base16 and per-tint color overrides, read from `workon.review.theme.*` git config /// (CS1, user-configurable colors tier) and applied on top of an already-resolved [`Palette`] via /// [`Palette::apply_overrides`]. `slots` is private — built only through [`ThemeOverrides::set_slot`] -/// so the 0–15 index invariant lives in one place; the 11 tint fields mirror +/// 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-029'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_subtle: Option, - pub del_strong: Option, - pub add_subtle: Option, - pub add_strong: Option, - pub del_staged_subtle: Option, - pub del_staged_strong: Option, - pub add_staged_subtle: Option, - pub add_staged_strong: Option, + 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, @@ -189,6 +199,88 @@ 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 @@ -278,19 +370,38 @@ pub struct Palette { /// [`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-029'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, @@ -401,14 +512,20 @@ impl Palette { let base = Base16::EIGHTIES_DARK; 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: Color::Rgb(64, 38, 40), + add_staged_line_bg: Color::Rgb(24, 34, 26), + add_staged_edit_bg: Color::Rgb(34, 50, 38), + // 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), Color::Rgb(34, 50, 38)), + del_staged_fg: staged_foreground(base.slot(8), base.slot(0), Color::Rgb(64, 38, 40)), cursor_bg: Color::Rgb(45, 50, 90), selection_bg: Color::Rgb(30, 66, 66), cursor_unfocused_bg: Color::Rgb(35, 38, 55), @@ -439,10 +556,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 { @@ -453,25 +570,32 @@ 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 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), cursor_unfocused_bg: tint_toward(blue, base00, CURSOR_UNFOCUSED), @@ -519,23 +643,33 @@ impl Palette { }; let red = base.slot(8); // base08 — probed ANSI red let green = base.slot(11); // base0B — probed ANSI green - // (subtle, strong, staged_subtle, staged_strong): how far each wash blends from the - // accent toward the probed background. Light reuses `Palette::light`'s tuned ratios. - let (subtle, strong, staged_subtle, staged_strong) = if light { + // (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: tint_toward(red, background, subtle), - del_strong: tint_toward(red, background, strong), - add_subtle: tint_toward(green, background, subtle), - add_strong: tint_toward(green, background, strong), - del_staged_subtle: tint_toward(red, background, staged_subtle), - del_staged_strong: tint_toward(red, background, staged_strong), - add_staged_subtle: tint_toward(green, background, staged_subtle), - add_staged_strong: tint_toward(green, background, 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, cursor_unfocused_bg: curated.cursor_unfocused_bg, @@ -574,46 +708,51 @@ impl Palette { /// 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: subtle vs strong read as distinct steps, staged reads dimmer (closer to + /// 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-029's NO_COLOR note). pub fn mono(light: bool) -> Self { - // (subtle, strong, staged_subtle, staged_strong, cursor, selection, cursor_unfocused) - let (subtle, strong, staged_subtle, staged_strong, 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), - ) - }; + // (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_subtle: subtle, - del_strong: strong, - add_subtle: subtle, - add_strong: strong, - del_staged_subtle: staged_subtle, - del_staged_strong: staged_strong, - add_staged_subtle: staged_subtle, - add_staged_strong: staged_strong, + 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, @@ -664,18 +803,22 @@ impl Palette { /// 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), 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. + /// 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 11 tint override - /// keys' job, applied last and verbatim below, so a slot override can't silently reshape a - /// hand-tuned wash it wasn't asked to touch. + /// 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] { @@ -687,6 +830,12 @@ impl Palette { 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; } @@ -713,29 +862,41 @@ impl Palette { } // Tint overrides assign last and verbatim — unaffected by any slot override above. - if let Some(color) = overrides.del_subtle { - self.del_subtle = color; + 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_strong { - self.del_strong = color; + if let Some(color) = overrides.del_staged_line_bg { + self.del_staged_line_bg = color; } - if let Some(color) = overrides.add_subtle { - self.add_subtle = color; + if let Some(color) = overrides.del_staged_edit_bg { + self.del_staged_edit_bg = color; } - if let Some(color) = overrides.add_strong { - self.add_strong = color; + if let Some(color) = overrides.add_staged_line_bg { + self.add_staged_line_bg = color; } - if let Some(color) = overrides.del_staged_subtle { - self.del_staged_subtle = color; + if let Some(color) = overrides.add_staged_edit_bg { + self.add_staged_edit_bg = color; } - if let Some(color) = overrides.del_staged_strong { - self.del_staged_strong = color; + if let Some(color) = overrides.add_fg { + self.add_fg = color; } - if let Some(color) = overrides.add_staged_subtle { - self.add_staged_subtle = color; + if let Some(color) = overrides.del_fg { + self.del_fg = color; } - if let Some(color) = overrides.add_staged_strong { - self.add_staged_strong = 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; @@ -783,14 +944,14 @@ 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.cursor_unfocused_bg, Color::Rgb(35, 38, 55)); @@ -896,30 +1057,31 @@ 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] @@ -1040,11 +1202,11 @@ mod tests { let palette = Palette::from_terminal(probed); let red = probed.slot(8); let green = probed.slot(11); - assert_eq!(palette.del_subtle, tint_toward(red, bg, 0.90)); - assert_eq!(palette.del_strong, tint_toward(red, bg, 0.75)); - assert_eq!(palette.add_subtle, tint_toward(green, bg, 0.90)); - assert_eq!(palette.add_strong, tint_toward(green, bg, 0.75)); - assert_ne!(palette.del_subtle, Palette::dark().del_subtle); + 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] @@ -1056,12 +1218,12 @@ mod tests { let palette = Palette::from_terminal(probed); let red = probed.slot(8); let green = probed.slot(11); - assert_eq!(palette.del_staged_subtle, tint_toward(red, bg, 0.94)); - assert_eq!(palette.del_staged_strong, tint_toward(red, bg, 0.85)); - assert_eq!(palette.add_staged_subtle, tint_toward(green, bg, 0.94)); - assert_eq!(palette.add_staged_strong, tint_toward(green, bg, 0.85)); - assert_ne!(palette.del_staged_subtle, palette.del_subtle); - assert_ne!(palette.add_staged_strong, palette.add_strong); + 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] @@ -1071,8 +1233,8 @@ mod tests { let bg = Color::Rgb(0xf5, 0xf5, 0xf5); let probed = probed_base16(bg); let palette = Palette::from_terminal(probed); - assert_eq!(palette.del_subtle, tint_toward(probed.slot(8), bg, 0.88)); - assert_eq!(palette.add_strong, tint_toward(probed.slot(11), bg, 0.65)); + 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] @@ -1139,24 +1301,200 @@ 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-029 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 mut overrides = ThemeOverrides::default(); + overrides.add_fg = Some(Color::Rgb(1, 2, 3)); + overrides.del_fg = Some(Color::Rgb(4, 5, 6)); + overrides.add_staged_fg = Some(Color::Rgb(7, 8, 9)); + overrides.del_staged_fg = Some(Color::Rgb(10, 11, 12)); + 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!( @@ -1189,7 +1527,7 @@ mod tests { 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_subtle, before.del_subtle); + 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!( @@ -1245,14 +1583,14 @@ mod tests { #[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_subtle + 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_subtle, Palette::dark().del_subtle); + assert_eq!(t.del_line_bg, Palette::dark().del_line_bg); } #[test] @@ -1290,14 +1628,14 @@ mod tests { for light in [false, true] { let t = Palette::mono(light); for wash in [ - t.del_subtle, - t.del_strong, - t.add_subtle, - t.add_strong, - t.del_staged_subtle, - t.del_staged_strong, - t.add_staged_subtle, - t.add_staged_strong, + 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, @@ -1307,27 +1645,27 @@ mod tests { // 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_subtle, t.add_subtle); - assert_eq!(t.del_strong, t.add_strong); - assert_eq!(t.del_staged_subtle, t.add_staged_subtle); - assert_eq!(t.del_staged_strong, t.add_staged_strong); + 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); - // Subtle vs strong remain visibly distinct steps. - assert_ne!(t.del_subtle, t.del_strong); - assert_ne!(t.del_staged_subtle, t.del_staged_strong); + // 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_subtle, _, _) = rgb(t.del_staged_subtle); - let (subtle, _, _) = rgb(t.del_subtle); - let (staged_strong, _, _) = rgb(t.del_staged_strong); - let (strong, _, _) = rgb(t.del_strong); + 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_subtle > subtle, "staged should sit closer to white"); - assert!(staged_strong > strong, "staged should sit closer to white"); + assert!(staged_line > line, "staged should sit closer to white"); + assert!(staged_edit > edit, "staged should sit closer to white"); } else { - assert!(staged_subtle < subtle, "staged should sit closer to black"); - assert!(staged_strong < strong, "staged should sit closer to black"); + 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 @@ -1370,9 +1708,9 @@ mod tests { #[test] fn mono_dark_ladder_sits_near_black_and_light_ladder_near_white() { - let (r, _, _) = rgb(Palette::mono(false).del_strong); + 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_strong); + let (r, _, _) = rgb(Palette::mono(true).del_edit_bg); assert!(r > 128, "light ladder should be a pale gray"); } } From 56110364e0800cc7cf1bc5eb109ad7463bd0d18d Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 22 Jul 2026 18:09:34 -0400 Subject: [PATCH 172/203] style(review): bind dark() staged-fg washes and fix a test init --- git-workon-review/src/theme.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 49c4ccf..b4da4d5 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -510,6 +510,11 @@ 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_line_bg: Color::Rgb(60, 24, 24), @@ -517,15 +522,15 @@ impl Palette { 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: Color::Rgb(64, 38, 40), + del_staged_edit_bg, add_staged_line_bg: Color::Rgb(24, 34, 26), - add_staged_edit_bg: Color::Rgb(34, 50, 38), + 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), Color::Rgb(34, 50, 38)), - del_staged_fg: staged_foreground(base.slot(8), base.slot(0), Color::Rgb(64, 38, 40)), + 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), cursor_unfocused_bg: Color::Rgb(35, 38, 55), @@ -1482,11 +1487,13 @@ mod tests { #[test] fn apply_overrides_reads_the_four_new_fg_tint_keys_verbatim() { - let mut overrides = ThemeOverrides::default(); - overrides.add_fg = Some(Color::Rgb(1, 2, 3)); - overrides.del_fg = Some(Color::Rgb(4, 5, 6)); - overrides.add_staged_fg = Some(Color::Rgb(7, 8, 9)); - overrides.del_staged_fg = Some(Color::Rgb(10, 11, 12)); + 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)); From a3c5f1d2e8098e15b06af2cc14211e3063f3b5bd Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 22 Jul 2026 18:50:53 -0400 Subject: [PATCH 173/203] feat(review): add workon.review.diff.text foreground modes --- git-workon-review/src/app.rs | 97 ++++++- git-workon-review/src/config.rs | 13 + git-workon-review/src/render.rs | 459 ++++++++++++++++++++++++++++++-- 3 files changed, 543 insertions(+), 26 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index ee2e713..d43ab2e 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -624,6 +624,26 @@ pub enum Role { Staged, } +/// `workon.review.diff.text` (see ADR-029'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 @@ -759,6 +779,18 @@ fn parse_diff_zoom(raw: &str) -> Option { } } +/// Parse `workon.review.diff.text` (CS11) into a [`DiffTextMode`]. Canonical strings mirror the +/// variant names: `syntax`, `tint`, `edit`. `None` on anything else — [`App::apply_view_config`] +/// falls back to [`DiffTextMode::default`] and warns. +fn parse_diff_text(raw: &str) -> Option { + match raw { + "syntax" => Some(DiffTextMode::Syntax), + "tint" => Some(DiffTextMode::Tint), + "edit" => Some(DiffTextMode::Edit), + _ => None, + } +} + /// 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. /// `render.rs`'s `render_summary` never matches on this directly — it only calls @@ -1249,6 +1281,10 @@ pub struct App { /// 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, @@ -1522,6 +1558,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(), @@ -3959,8 +3996,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 @@ -4045,6 +4091,17 @@ impl App { }; self.set_zoom(zoom); + let diff_text = match &raw.diff_text { + Some(t) => parse_diff_text(t).unwrap_or_else(|| { + warnings.push(format!( + "workon.review.diff.text = '{t}' unrecognized; using default" + )); + DiffTextMode::default() + }), + None => DiffTextMode::default(), + }; + self.set_diff_text(diff_text); + warnings } @@ -5182,8 +5239,8 @@ 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, SCROLLOFF, }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::{RawViewConfig, ReviewConfig}; @@ -10175,6 +10232,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] @@ -10363,6 +10421,37 @@ 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); + assert!(warnings[0].contains("diff.text")); + } + // ── `reload-config` (`R`): request flag + mid-session view-config apply ──── #[test] diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index a656a63..30de6cd 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -43,6 +43,7 @@ //! [workon "review.diff"] //! layout = split //! zoom = combined +//! text = syntax ; syntax | tint | edit (default: syntax) //! ``` //! //! ## Live reload (`reload-config`) @@ -137,6 +138,7 @@ 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 @@ -434,6 +436,13 @@ impl<'repo> ReviewConfig<'repo> { self.get_view_string(View::Diff, "zoom") } + /// Get `workon.review.diff.text`, raw. `None` if unset — see + /// [ADR-029](../../../docs/adr/029-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 @@ -449,6 +458,7 @@ 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(), } } @@ -634,6 +644,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"); @@ -657,6 +668,7 @@ mod tests { config.diff_zoom().expect("zoom"), Some("staged".to_string()) ); + assert_eq!(config.diff_text().expect("text"), Some("tint".to_string())); } #[test] @@ -671,6 +683,7 @@ 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] diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index c35e35d..1520f39 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -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; @@ -295,14 +296,22 @@ struct Segment { 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-029'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-029'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]; @@ -316,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(); @@ -335,15 +348,21 @@ fn compose_segments( .rev() .find(|(s, e, _)| mid >= *s && mid < *e) .map(|(_, _, c)| *c); - let (fg, italic) = fg_spans + let italic = fg_spans .and_then(|fgs| fgs.iter().find(|s| mid >= s.start && mid < s.end)) - .map(|s| { - ( - theme.syntax(s.capture), - crate::theme::syntax_italic(s.capture), - ) - }) - .unwrap_or((theme.foreground, false)); + .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(|| { + 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, @@ -441,6 +460,41 @@ fn add_bg_pair(mode: AttributionMode, new_lnum: u32, theme: &Palette) -> (Color, } } +/// The tint foreground for a Del cell at `old_lnum`, given `mode` — the same attribution match as +/// [`del_bg_pair`] (locked decision #6: staged-ness must resolve identically for background and +/// foreground, not through a second path), 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 { + match mode { + AttributionMode::Plain => theme.del_fg, + AttributionMode::StagedUniform => theme.del_staged_fg, + AttributionMode::Attributed(attribution) => { + if attribution.del_is_staged(old_lnum) { + theme.del_staged_fg + } else { + theme.del_fg + } + } + } +} + +/// The tint foreground for an Add cell at `new_lnum`, given `mode` — the same attribution match as +/// [`add_bg_pair`], 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 { + match mode { + AttributionMode::Plain => theme.add_fg, + AttributionMode::StagedUniform => theme.add_staged_fg, + AttributionMode::Attributed(attribution) => { + if attribution.add_is_unstaged(new_lnum) { + theme.add_fg + } else { + theme.add_staged_fg + } + } + } +} + /// Which side of the aligned pair a pane line is being built for — determines which of /// [`FileView`]'s two parallel (text, highlight) sources to read. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -557,6 +611,15 @@ fn pan_spans(spans: Vec>, cols: usize, theme: &Palette) -> Vec, ) -> Vec> { let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); + let mut fg_override_spans: Vec<(usize, usize, Color)> = Vec::new(); if let Some((line_bg, edit_bg)) = emphasis { if is_word_pair { bg_spans.push((0, text.len(), line_bg)); @@ -583,9 +649,29 @@ fn content_spans( // domain term precisely because this branch would falsify "word"). bg_spans.push((0, text.len(), edit_bg)); } + + if let Some(fg) = tint_fg { + match text_mode { + DiffTextMode::Syntax => {} + DiffTextMode::Tint => { + fg_override_spans.push((0, text.len(), 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, fg)); + } + } else { + fg_override_spans.push((0, text.len(), 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( @@ -620,6 +706,7 @@ fn build_pane_line( content_w: usize, theme: &Palette, hscroll: usize, + text_mode: DiffTextMode, ) -> Line<'static> { match row { Row::Filler => { @@ -640,10 +727,16 @@ fn build_pane_line( let gutter = format!("{n:>gutter_w$} "); 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::Context | CellKind::Filler => None, + let (emphasis, tint_fg) = match kind { + CellKind::Del => ( + Some(del_bg_pair(mode, n as u32, theme)), + Some(del_tint_fg(mode, n as u32, theme)), + ), + CellKind::Add => ( + Some(add_bg_pair(mode, n as u32, theme)), + Some(add_tint_fg(mode, n as u32, theme)), + ), + CellKind::Context | CellKind::Filler => (None, None), }; spans.extend(content_spans( text, @@ -653,6 +746,8 @@ fn build_pane_line( is_word_pair, theme, hscroll, + text_mode, + tint_fg, )); Line::from(spans) } @@ -2033,6 +2128,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); @@ -2110,6 +2207,7 @@ fn render_pane_sbs( old_area.width as usize, theme, hscroll, + text_mode, ); let new_line = build_pane_line( view, @@ -2123,6 +2221,7 @@ 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 { @@ -2195,6 +2294,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 } => ( @@ -2238,6 +2338,11 @@ fn build_inline_line( CellKind::Add => new_opt.map(|n| add_bg_pair(mode, n as u32, theme)), CellKind::Context | CellKind::Filler => None, }; + let tint_fg = match kind { + CellKind::Del => old_opt.map(|n| del_tint_fg(mode, n as u32, theme)), + CellKind::Add => new_opt.map(|n| add_tint_fg(mode, n as u32, theme)), + CellKind::Context | CellKind::Filler => None, + }; spans.extend(content_spans( text, hl, @@ -2246,6 +2351,8 @@ fn build_inline_line( is_word_pair, theme, hscroll, + text_mode, + tint_fg, )); Line::from(spans) } @@ -2269,6 +2376,8 @@ fn render_pane_inline( // 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); @@ -2333,6 +2442,7 @@ 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 { @@ -2363,16 +2473,17 @@ mod tests { use unicode_width::UnicodeWidthChar; use super::{ - changeset_prefix_spans, compose_segments, hscroll_cut, pan_spans, pane_header_label_style, - render, STATUS_PLACEHOLDER, + changeset_prefix_spans, compose_segments, content_spans, hscroll_cut, pan_spans, + pane_header_label_style, render, STATUS_PLACEHOLDER, }; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; - use crate::app::{App, EffectiveZoom, Role}; + 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 @@ -2407,11 +2518,186 @@ mod tests { capture: keyword, }, ]; - let segments = compose_segments(8, &[], Some(&fgs), &theme); + let segments = compose_segments(8, &[], Some(&fgs), &[], &theme); assert!(segments[0].italic, "comment segment renders italic"); assert!(!segments[1].italic, "keyword segment stays upright"); } + // ── CS11: `workon.review.diff.text` (`DiffTextMode`) foreground selection ────────── + + /// Every span's resolved foreground, in order — the shape these `content_spans` tests + /// assert on, since `Style` doesn't expose its fg as a bare `Color` any other way. + fn fgs_of(spans: &[TSpan<'static>]) -> Vec> { + spans.iter().map(|s| s.style.fg).collect() + } + + #[test] + fn content_spans_syntax_mode_ignores_tint_fg_entirely() { + // The changeset's primary gate (ADR-029 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 = Some((theme.del_line_bg, theme.del_edit_bg)); + 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, + words, + is_word_pair, + &theme, + 0, + DiffTextMode::Syntax, + Some(theme.del_fg), + ); + let without_tint = content_spans( + "hello", + None, + emphasis, + words, + is_word_pair, + &theme, + 0, + DiffTextMode::Syntax, + None, + ); + 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 — even if a caller somehow passed a `tint_fg` alongside it + // (callers never do), no mode may paint it, since there is no edit/line wash for a tint + // foreground 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, + Some(theme.del_fg), + ); + assert!( + fgs_of(&spans).iter().all(|fg| *fg != Some(theme.del_fg)), + "context line must not take the 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((theme.add_line_bg, theme.add_edit_bg)); + let spans = content_spans( + "hello", + None, + emphasis, + &[WordSpan { start: 0, end: 2 }], + true, + &theme, + 0, + DiffTextMode::Tint, + Some(theme.add_fg), + ); + 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((theme.add_line_bg, theme.add_edit_bg)); + // "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, + Some(theme.add_fg), + ); + 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((theme.del_line_bg, theme.del_edit_bg)); + let spans = content_spans( + "hello", + None, + emphasis, + &[], // no word spans: this line has no pair to word-diff against + false, + &theme, + 0, + DiffTextMode::Edit, + Some(theme.del_fg), + ); + 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 { @@ -3148,6 +3434,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() From db65470ab48b2752eefb58e9fa92b706978fd686 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 22 Jul 2026 18:52:32 -0400 Subject: [PATCH 174/203] refactor(review): resolve syntax fg and italic from one lookup --- git-workon-review/src/render.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 1520f39..d413d16 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -348,8 +348,12 @@ fn compose_segments( .rev() .find(|(s, e, _)| mid >= *s && mid < *e) .map(|(_, _, c)| *c); - let italic = fg_spans - .and_then(|fgs| fgs.iter().find(|s| mid >= s.start && mid < s.end)) + // 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 @@ -358,8 +362,7 @@ fn compose_segments( .find(|(s, e, _)| mid >= *s && mid < *e) .map(|(_, _, c)| *c) .unwrap_or_else(|| { - fg_spans - .and_then(|fgs| fgs.iter().find(|s| mid >= s.start && mid < s.end)) + syntax_hit .map(|s| theme.syntax(s.capture)) .unwrap_or(theme.foreground) }); From 219710a04ed8f385ce64239bb6e3a1a019a62ed8 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 23 Jul 2026 12:24:04 -0400 Subject: [PATCH 175/203] fix(review): share the staged-ness attribution cascade --- git-workon-review/src/render.rs | 88 +++++++++++++++------------------ 1 file changed, 41 insertions(+), 47 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index d413d16..32f0b04 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -427,21 +427,36 @@ fn attribution_mode(role: Role, attribution: &Option) -> Attributio } } +/// Whether a Del cell at `old_lnum` is staged, given `mode` — the single staged-ness decision +/// shared by [`del_bg_pair`] and [`del_tint_fg`] (locked decision #6: staged-ness must resolve +/// identically for background and foreground, not through a second path). +fn del_is_staged(mode: AttributionMode, old_lnum: u32) -> bool { + match mode { + AttributionMode::Plain => false, + AttributionMode::StagedUniform => true, + AttributionMode::Attributed(attribution) => attribution.del_is_staged(old_lnum), + } +} + +/// Whether an Add cell at `new_lnum` is staged, given `mode` — the single staged-ness decision +/// shared by [`add_bg_pair`] and [`add_tint_fg`] (locked decision #6). +fn add_is_staged(mode: AttributionMode, new_lnum: u32) -> bool { + match mode { + AttributionMode::Plain => false, + AttributionMode::StagedUniform => true, + AttributionMode::Attributed(attribution) => !attribution.add_is_unstaged(new_lnum), + } +} + /// 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); - match mode { - AttributionMode::Plain => unstaged, - AttributionMode::StagedUniform => staged, - AttributionMode::Attributed(attribution) => { - if attribution.del_is_staged(old_lnum) { - staged - } else { - unstaged - } - } + if del_is_staged(mode, old_lnum) { + staged + } else { + unstaged } } @@ -450,51 +465,30 @@ fn del_bg_pair(mode: AttributionMode, old_lnum: u32, theme: &Palette) -> (Color, 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); - match mode { - AttributionMode::Plain => unstaged, - AttributionMode::StagedUniform => staged, - AttributionMode::Attributed(attribution) => { - if attribution.add_is_unstaged(new_lnum) { - unstaged - } else { - staged - } - } + if add_is_staged(mode, new_lnum) { + staged + } else { + unstaged } } -/// The tint foreground for a Del cell at `old_lnum`, given `mode` — the same attribution match as -/// [`del_bg_pair`] (locked decision #6: staged-ness must resolve identically for background and -/// foreground, not through a second path), resolved from `theme`'s unstaged vs. staged Del -/// foreground fields ([`Palette::del_fg`]/[`Palette::del_staged_fg`]). +/// 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 { - match mode { - AttributionMode::Plain => theme.del_fg, - AttributionMode::StagedUniform => theme.del_staged_fg, - AttributionMode::Attributed(attribution) => { - if attribution.del_is_staged(old_lnum) { - theme.del_staged_fg - } else { - theme.del_fg - } - } + 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` — the same attribution match as -/// [`add_bg_pair`], resolved from `theme`'s unstaged vs. staged Add foreground fields -/// ([`Palette::add_fg`]/[`Palette::add_staged_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 { - match mode { - AttributionMode::Plain => theme.add_fg, - AttributionMode::StagedUniform => theme.add_staged_fg, - AttributionMode::Attributed(attribution) => { - if attribution.add_is_unstaged(new_lnum) { - theme.add_fg - } else { - theme.add_staged_fg - } - } + if add_is_staged(mode, new_lnum) { + theme.add_staged_fg + } else { + theme.add_fg } } From e34511c55d51cbff7e46133d7666d88a98fc40fd Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 23 Jul 2026 12:31:23 -0400 Subject: [PATCH 176/203] fix(review): bundle diff text-mode params into one struct --- git-workon-review/src/render.rs | 182 +++++++++++++++++++------------- 1 file changed, 107 insertions(+), 75 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 32f0b04..f13060c 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -598,24 +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, - tint_fg: Option, ) -> Vec> { let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); let mut fg_override_spans: Vec<(usize, usize, Color)> = Vec::new(); - if let Some((line_bg, edit_bg)) = emphasis { + if let Some(LineEmphasis { + line_bg, + edit_bg, + tint_fg, + }) = emphasis + { if is_word_pair { bg_spans.push((0, text.len(), line_bg)); for s in word_spans { @@ -647,22 +661,20 @@ fn content_spans( bg_spans.push((0, text.len(), edit_bg)); } - if let Some(fg) = tint_fg { - match text_mode { - DiffTextMode::Syntax => {} - DiffTextMode::Tint => { - fg_override_spans.push((0, text.len(), 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, fg)); - } - } else { - fg_override_spans.push((0, text.len(), fg)); + 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)); } } } @@ -724,16 +736,24 @@ fn build_pane_line( let gutter = format!("{n:>gutter_w$} "); let mut spans = vec![TSpan::styled(gutter, Style::default().fg(theme.gutter))]; - let (emphasis, tint_fg) = match kind { - CellKind::Del => ( - Some(del_bg_pair(mode, n as u32, theme)), - Some(del_tint_fg(mode, n as u32, theme)), - ), - CellKind::Add => ( - Some(add_bg_pair(mode, n as u32, theme)), - Some(add_tint_fg(mode, n as u32, theme)), - ), - CellKind::Context | CellKind::Filler => (None, None), + let emphasis = match kind { + 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( text, @@ -744,7 +764,6 @@ fn build_pane_line( theme, hscroll, text_mode, - tint_fg, )); Line::from(spans) } @@ -2331,13 +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::Context | CellKind::Filler => None, - }; - let tint_fg = match kind { - CellKind::Del => old_opt.map(|n| del_tint_fg(mode, n as u32, theme)), - CellKind::Add => new_opt.map(|n| add_tint_fg(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( @@ -2349,7 +2377,6 @@ fn build_inline_line( theme, hscroll, text_mode, - tint_fg, )); Line::from(spans) } @@ -2471,7 +2498,7 @@ mod tests { use super::{ changeset_prefix_spans, compose_segments, content_spans, hscroll_cut, pan_spans, - pane_header_label_style, render, STATUS_PLACEHOLDER, + pane_header_label_style, render, LineEmphasis, STATUS_PLACEHOLDER, }; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; @@ -2534,31 +2561,35 @@ mod tests { // 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 = Some((theme.del_line_bg, theme.del_edit_bg)); + 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, + emphasis(theme.del_fg), words, is_word_pair, &theme, 0, DiffTextMode::Syntax, - Some(theme.del_fg), ); let without_tint = content_spans( "hello", None, - emphasis, + emphasis(theme.foreground), words, is_word_pair, &theme, 0, DiffTextMode::Syntax, - None, ); assert_eq!( with_tint, without_tint, @@ -2578,25 +2609,17 @@ mod tests { #[test] fn content_spans_context_lines_never_take_tint_fg_in_any_mode() { // Locked decision #3: the knob governs changed lines only. `emphasis: None` is what - // marks a Context/Filler line — even if a caller somehow passed a `tint_fg` alongside it - // (callers never do), no mode may paint it, since there is no edit/line wash for a tint - // foreground to attach meaning to. + // 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, - Some(theme.del_fg), - ); + let spans = content_spans("hello", None, None, &[], false, &theme, 0, mode); assert!( - fgs_of(&spans).iter().all(|fg| *fg != Some(theme.del_fg)), - "context line must not take the tint fg under {mode:?}" + fgs_of(&spans) + .iter() + .all(|fg| *fg == Some(theme.foreground)), + "context line must render plain (no tint fg) under {mode:?}" ); } } @@ -2604,7 +2627,11 @@ mod tests { #[test] fn content_spans_tint_mode_paints_the_tint_fg_across_the_full_line() { let theme = Palette::dark(); - let emphasis = Some((theme.add_line_bg, theme.add_edit_bg)); + 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, @@ -2614,7 +2641,6 @@ mod tests { &theme, 0, DiffTextMode::Tint, - Some(theme.add_fg), ); assert!( fgs_of(&spans).iter().all(|fg| *fg == Some(theme.add_fg)), @@ -2626,7 +2652,11 @@ mod tests { #[test] fn content_spans_edit_mode_paints_only_the_word_span_on_a_paired_line() { let theme = Palette::dark(); - let emphasis = Some((theme.add_line_bg, theme.add_edit_bg)); + 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( @@ -2638,7 +2668,6 @@ mod tests { &theme, 0, DiffTextMode::Edit, - Some(theme.add_fg), ); let tinted: Vec<&TSpan> = spans .iter() @@ -2670,7 +2699,11 @@ mod tests { // (`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((theme.del_line_bg, theme.del_edit_bg)); + 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, @@ -2680,7 +2713,6 @@ mod tests { &theme, 0, DiffTextMode::Edit, - Some(theme.del_fg), ); assert!( fgs_of(&spans).iter().all(|fg| *fg == Some(theme.del_fg)), From 05bdcb629b26a30e553fc64a18bd1de3d634c850 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 22 Jul 2026 20:16:13 -0400 Subject: [PATCH 177/203] docs(review): record the config validation completeness decision --- .../028-review-git-native-config-schema.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/adr/028-review-git-native-config-schema.md b/docs/adr/028-review-git-native-config-schema.md index a7f5222..eb23c45 100644 --- a/docs/adr/028-review-git-native-config-schema.md +++ b/docs/adr/028-review-git-native-config-schema.md @@ -106,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 From 04ce4bfb7c12b7fe1e19fa8c3eb31a70e87d51f3 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 22 Jul 2026 20:25:13 -0400 Subject: [PATCH 178/203] feat(review): warn on unknown workon.review.* config keys --- git-workon-review/src/app.rs | 222 +++++++++++++----- git-workon-review/src/config.rs | 400 +++++++++++++++++++++++++++++++- 2 files changed, 551 insertions(+), 71 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index d43ab2e..74eca5e 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -719,76 +719,131 @@ 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. +/// 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(", ") +} + +/// 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`") +} + +/// `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. +const OUTLINE_MODE_OPTIONS: &[(&str, OutlineMode)] = &[ + ("flat", OutlineMode::Flat), + ("stack", OutlineMode::Stack), + ("tree", OutlineMode::Tree), + ("stack-tree", OutlineMode::StackTree), +]; + +/// Parse `workon.review.outline.mode` (CS7) into an [`OutlineMode`]. `None` on anything not in +/// [`OUTLINE_MODE_OPTIONS`] — [`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, - } + OUTLINE_MODE_OPTIONS + .iter() + .find(|(name, _)| *name == raw) + .map(|(_, mode)| *mode) } -/// 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. +/// `workon.review.outline.order` (CS3)'s valid config strings, kebab-cased mirrors of the +/// [`OutlineOrder`] variant names. +const OUTLINE_ORDER_OPTIONS: &[(&str, OutlineOrder)] = &[ + ("head-first", OutlineOrder::HeadFirst), + ("base-first", OutlineOrder::BaseFirst), +]; + +/// Parse `workon.review.outline.order` (CS3) into an [`OutlineOrder`]. `None` on anything not in +/// [`OUTLINE_ORDER_OPTIONS`] — [`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, - } + OUTLINE_ORDER_OPTIONS + .iter() + .find(|(name, _)| *name == raw) + .map(|(_, order)| *order) } -/// 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. +/// `workon.review.icons` (CS5)'s valid config strings, kebab-cased mirrors of the [`IconMode`] +/// variant names. +const ICON_MODE_OPTIONS: &[(&str, IconMode)] = + &[("none", IconMode::None), ("nerd", IconMode::Nerd)]; + +/// Parse `workon.review.icons` (CS5) into an [`IconMode`]. `None` on anything not in +/// [`ICON_MODE_OPTIONS`] — [`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, - } + ICON_MODE_OPTIONS + .iter() + .find(|(name, _)| *name == raw) + .map(|(_, mode)| *mode) } -/// 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. +/// `workon.review.diff.layout` (CS7)'s valid config strings, mirroring the [`Layout`] variant +/// names. +const DIFF_LAYOUT_OPTIONS: &[(&str, Layout)] = &[("sbs", Layout::Sbs), ("inline", Layout::Inline)]; + +/// Parse `workon.review.diff.layout` (CS7) into a [`Layout`]. `None` on anything not in +/// [`DIFF_LAYOUT_OPTIONS`] — [`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, - } + DIFF_LAYOUT_OPTIONS + .iter() + .find(|(name, _)| *name == raw) + .map(|(_, layout)| *layout) } -/// 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. +/// `workon.review.diff.zoom` (CS7)'s valid config strings, mirroring the [`Zoom`] variant names. +const DIFF_ZOOM_OPTIONS: &[(&str, Zoom)] = &[ + ("split", Zoom::Split), + ("combined", Zoom::Combined), + ("unstaged", Zoom::Unstaged), + ("staged", Zoom::Staged), +]; + +/// Parse `workon.review.diff.zoom` (CS7) into a [`Zoom`]. `None` on anything not in +/// [`DIFF_ZOOM_OPTIONS`] — [`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, - } + DIFF_ZOOM_OPTIONS + .iter() + .find(|(name, _)| *name == raw) + .map(|(_, zoom)| *zoom) } -/// Parse `workon.review.diff.text` (CS11) into a [`DiffTextMode`]. Canonical strings mirror the -/// variant names: `syntax`, `tint`, `edit`. `None` on anything else — [`App::apply_view_config`] -/// falls back to [`DiffTextMode::default`] and warns. +/// `workon.review.diff.text` (CS11)'s valid config strings, mirroring the [`DiffTextMode`] +/// variant names. +const DIFF_TEXT_OPTIONS: &[(&str, DiffTextMode)] = &[ + ("syntax", DiffTextMode::Syntax), + ("tint", DiffTextMode::Tint), + ("edit", DiffTextMode::Edit), +]; + +/// Parse `workon.review.diff.text` (CS11) into a [`DiffTextMode`]. `None` on anything not in +/// [`DIFF_TEXT_OPTIONS`] — see [ADR-029](../../../docs/adr/029-review-theming-base16-hybrid.md)'s +/// "Revised (CS11, diff foreground/background split)" section. +/// [`App::apply_view_config`] falls back to [`DiffTextMode::default`] and warns. fn parse_diff_text(raw: &str) -> Option { - match raw { - "syntax" => Some(DiffTextMode::Syntax), - "tint" => Some(DiffTextMode::Tint), - "edit" => Some(DiffTextMode::Edit), - _ => None, - } + DIFF_TEXT_OPTIONS + .iter() + .find(|(name, _)| *name == raw) + .map(|(_, mode)| *mode) } /// CS4: which outline row a Header/Dir cursor selection resolves to — [`App::summary_target`]'s @@ -4027,7 +4082,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 } @@ -4038,8 +4094,11 @@ impl App { let mode = match &raw.outline_mode { Some(m) => parse_outline_mode(m).unwrap_or_else(|| { + let valid = valid_options_list(OUTLINE_MODE_OPTIONS); + let default = default_option_name(OUTLINE_MODE_OPTIONS); warnings.push(format!( - "workon.review.outline.mode = '{m}' unrecognized; using default" + "workon.review.outline.mode = '{m}' unrecognized (valid: {valid}); \ + using default '{default}'" )); OutlineMode::default() }), @@ -4049,8 +4108,11 @@ impl App { let order = match &raw.outline_order { Some(o) => parse_outline_order(o).unwrap_or_else(|| { + let valid = valid_options_list(OUTLINE_ORDER_OPTIONS); + let default = default_option_name(OUTLINE_ORDER_OPTIONS); warnings.push(format!( - "workon.review.outline.order = '{o}' unrecognized; using default" + "workon.review.outline.order = '{o}' unrecognized (valid: {valid}); \ + using default '{default}'" )); OutlineOrder::default() }), @@ -4060,8 +4122,11 @@ impl App { let icons = match &raw.icons { Some(i) => parse_icon_mode(i).unwrap_or_else(|| { + let valid = valid_options_list(ICON_MODE_OPTIONS); + let default = default_option_name(ICON_MODE_OPTIONS); warnings.push(format!( - "workon.review.icons = '{i}' unrecognized; using default" + "workon.review.icons = '{i}' unrecognized (valid: {valid}); \ + using default '{default}'" )); IconMode::default() }), @@ -4071,8 +4136,11 @@ impl App { let layout = match &raw.diff_layout { Some(l) => parse_diff_layout(l).unwrap_or_else(|| { + let valid = valid_options_list(DIFF_LAYOUT_OPTIONS); + let default = default_option_name(DIFF_LAYOUT_OPTIONS); warnings.push(format!( - "workon.review.diff.layout = '{l}' unrecognized; using default" + "workon.review.diff.layout = '{l}' unrecognized (valid: {valid}); \ + using default '{default}'" )); Layout::default() }), @@ -4082,8 +4150,11 @@ impl App { let zoom = match &raw.diff_zoom { Some(z) => parse_diff_zoom(z).unwrap_or_else(|| { + let valid = valid_options_list(DIFF_ZOOM_OPTIONS); + let default = default_option_name(DIFF_ZOOM_OPTIONS); warnings.push(format!( - "workon.review.diff.zoom = '{z}' unrecognized; using default" + "workon.review.diff.zoom = '{z}' unrecognized (valid: {valid}); \ + using default '{default}'" )); Zoom::default() }), @@ -4093,8 +4164,11 @@ impl App { let diff_text = match &raw.diff_text { Some(t) => parse_diff_text(t).unwrap_or_else(|| { + let valid = valid_options_list(DIFF_TEXT_OPTIONS); + let default = default_option_name(DIFF_TEXT_OPTIONS); warnings.push(format!( - "workon.review.diff.text = '{t}' unrecognized; using default" + "workon.review.diff.text = '{t}' unrecognized (valid: {valid}); \ + using default '{default}'" )); DiffTextMode::default() }), @@ -10263,7 +10337,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] @@ -10294,7 +10377,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] @@ -10449,7 +10538,12 @@ mod tests { assert_eq!(app.diff_text, DiffTextMode::default()); assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("diff.text")); + // 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 ──── diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index 30de6cd..ebf7a16 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -151,9 +151,10 @@ pub struct RuntimeConfig { pub keymap: Keymap, pub palette: Palette, pub view_config: RawViewConfig, - /// `workon.review.theme.*` override warnings only — 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. + /// `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, } @@ -182,13 +183,16 @@ pub fn resolve_runtime(repo: &Repository, ctx: &PaletteContext) -> RuntimeConfig Err(_) => Palette::dark(), }; - let warnings = match config.theme_overrides() { + 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)); @@ -266,6 +270,104 @@ fn invalid_color_warning(key: &str, raw: &str) -> String { format!("workon.review.theme.{key}: invalid color {raw:?}, ignoring") } +// ── Unknown-key registry (config validation completeness) ────────────────────────────────── +// +// ADR-028'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 > @@ -285,7 +387,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, @@ -420,7 +522,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), } @@ -462,6 +564,20 @@ impl<'repo> ReviewConfig<'repo> { } } + /// 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). @@ -469,7 +585,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> { @@ -487,6 +603,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)] @@ -975,4 +1134,231 @@ mod tests { 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().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let cfg_path = repo.path().join("config"); + for v in ["a", "b"] { + let status = std::process::Command::new("git") + .args([ + "config", + "--file", + cfg_path.to_str().expect("config path utf8"), + "--add", + "workon.review.bogus-key", + v, + ]) + .status() + .expect("git config --add"); + assert!(status.success(), "git config --add failed"); + } + + 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" + ); + } } From 53da9b8fcd0f5cd8b4c893cb32fcbad330d1bcb5 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 22 Jul 2026 20:27:13 -0400 Subject: [PATCH 179/203] test(review): import width bounds into the app test module --- git-workon-review/src/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 74eca5e..af00198 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -5314,7 +5314,8 @@ mod tests { use super::{ build_file_views, find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, DiffState, DiffTextMode, EffectiveZoom, HitRegions, Layout, LoadedViews, Region, Role, Severity, - Summary, SummaryTarget, Zoom, DEFAULT_OUTLINE_WIDTH, HSCROLL_STEP, SCROLLOFF, + 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::{RawViewConfig, ReviewConfig}; From 6c541bfca831873ccd92102e162786b6543d5f99 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 23 Jul 2026 11:09:15 -0400 Subject: [PATCH 180/203] feat(review): name the expected format in invalid color warnings --- git-workon-review/src/config.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index ebf7a16..d8e3240 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -265,9 +265,13 @@ fn tint_slot<'a>(overrides: &'a mut ThemeOverrides, key: &str) -> Option<&'a mut } /// 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`]. +/// 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:?}, ignoring") + format!( + "workon.review.theme.{key}: invalid color {raw:?} (expected #rrggbb or rrggbb); ignoring" + ) } // ── Unknown-key registry (config validation completeness) ────────────────────────────────── @@ -999,7 +1003,13 @@ mod tests { .expect("theme_overrides"); assert!(overrides.is_empty(), "invalid value must not set the slot"); assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("base00")); + // 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] From 6df3e8665386f5df63c931c11ab7bad9e0481e88 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 23 Jul 2026 12:24:11 -0400 Subject: [PATCH 181/203] fix(review): use fixture layers in the dedup warning test --- git-workon-review/src/config.rs | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index d8e3240..9fd738c 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -1251,23 +1251,12 @@ mod tests { 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().build().expect("fixture build"); + 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 cfg_path = repo.path().join("config"); - for v in ["a", "b"] { - let status = std::process::Command::new("git") - .args([ - "config", - "--file", - cfg_path.to_str().expect("config path utf8"), - "--add", - "workon.review.bogus-key", - v, - ]) - .status() - .expect("git config --add"); - assert!(status.success(), "git config --add failed"); - } let warnings = ReviewConfig::new(repo) .unknown_key_warnings() From d415ce7d8ddddda3629c89ea28fb99624f648f2c Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 23 Jul 2026 12:31:07 -0400 Subject: [PATCH 182/203] fix(review): share the parse-warn-default shape across view settings --- git-workon-review/src/app.rs | 196 ++++++++++++------------------- git-workon-review/src/icons.rs | 3 +- git-workon-review/src/outline.rs | 2 +- 3 files changed, 80 insertions(+), 121 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index af00198..d458e67 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -745,8 +745,40 @@ fn default_option_name( .expect("T::default() has a canonical name listed in `options`") } +/// Look up `raw` in one of the CS7 `*_OPTIONS` tables below — `None` on anything not in +/// `options`, the "unrecognized" signal [`resolve_option`] falls back to a default and warns on. +fn parse_option(options: &[(&str, T)], raw: &str) -> Option { + options + .iter() + .find(|(name, _)| *name == raw) + .map(|(_, value)| *value) +} + +/// 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() + }) +} + /// `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. +/// [`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), @@ -754,63 +786,29 @@ const OUTLINE_MODE_OPTIONS: &[(&str, OutlineMode)] = &[ ("stack-tree", OutlineMode::StackTree), ]; -/// Parse `workon.review.outline.mode` (CS7) into an [`OutlineMode`]. `None` on anything not in -/// [`OUTLINE_MODE_OPTIONS`] — [`App::apply_view_config`] falls back to [`OutlineMode::default`] -/// and warns. -fn parse_outline_mode(raw: &str) -> Option { - OUTLINE_MODE_OPTIONS - .iter() - .find(|(name, _)| *name == raw) - .map(|(_, mode)| *mode) -} - /// `workon.review.outline.order` (CS3)'s valid config strings, kebab-cased mirrors of the -/// [`OutlineOrder`] variant names. +/// [`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), ]; -/// Parse `workon.review.outline.order` (CS3) into an [`OutlineOrder`]. `None` on anything not in -/// [`OUTLINE_ORDER_OPTIONS`] — [`App::apply_view_config`] falls back to -/// [`OutlineOrder::default`] and warns. -fn parse_outline_order(raw: &str) -> Option { - OUTLINE_ORDER_OPTIONS - .iter() - .find(|(name, _)| *name == raw) - .map(|(_, order)| *order) -} - /// `workon.review.icons` (CS5)'s valid config strings, kebab-cased mirrors of the [`IconMode`] -/// variant names. +/// 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)]; -/// Parse `workon.review.icons` (CS5) into an [`IconMode`]. `None` on anything not in -/// [`ICON_MODE_OPTIONS`] — [`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 { - ICON_MODE_OPTIONS - .iter() - .find(|(name, _)| *name == raw) - .map(|(_, mode)| *mode) -} - /// `workon.review.diff.layout` (CS7)'s valid config strings, mirroring the [`Layout`] variant -/// names. +/// 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)]; -/// Parse `workon.review.diff.layout` (CS7) into a [`Layout`]. `None` on anything not in -/// [`DIFF_LAYOUT_OPTIONS`] — [`App::apply_view_config`] falls back to [`Layout::default`] and -/// warns. -fn parse_diff_layout(raw: &str) -> Option { - DIFF_LAYOUT_OPTIONS - .iter() - .find(|(name, _)| *name == raw) - .map(|(_, layout)| *layout) -} - /// `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), @@ -818,34 +816,17 @@ const DIFF_ZOOM_OPTIONS: &[(&str, Zoom)] = &[ ("staged", Zoom::Staged), ]; -/// Parse `workon.review.diff.zoom` (CS7) into a [`Zoom`]. `None` on anything not in -/// [`DIFF_ZOOM_OPTIONS`] — [`App::apply_view_config`] falls back to [`Zoom::default`] and warns. -fn parse_diff_zoom(raw: &str) -> Option { - DIFF_ZOOM_OPTIONS - .iter() - .find(|(name, _)| *name == raw) - .map(|(_, zoom)| *zoom) -} - /// `workon.review.diff.text` (CS11)'s valid config strings, mirroring the [`DiffTextMode`] -/// variant names. +/// variant names — see [ADR-029](../../../docs/adr/029-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), ]; -/// Parse `workon.review.diff.text` (CS11) into a [`DiffTextMode`]. `None` on anything not in -/// [`DIFF_TEXT_OPTIONS`] — see [ADR-029](../../../docs/adr/029-review-theming-base16-hybrid.md)'s -/// "Revised (CS11, diff foreground/background split)" section. -/// [`App::apply_view_config`] falls back to [`DiffTextMode::default`] and warns. -fn parse_diff_text(raw: &str) -> Option { - DIFF_TEXT_OPTIONS - .iter() - .find(|(name, _)| *name == raw) - .map(|(_, mode)| *mode) -} - /// 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. /// `render.rs`'s `render_summary` never matches on this directly — it only calls @@ -4093,85 +4074,62 @@ impl App { self.set_outline_width(width); let mode = match &raw.outline_mode { - Some(m) => parse_outline_mode(m).unwrap_or_else(|| { - let valid = valid_options_list(OUTLINE_MODE_OPTIONS); - let default = default_option_name(OUTLINE_MODE_OPTIONS); - warnings.push(format!( - "workon.review.outline.mode = '{m}' unrecognized (valid: {valid}); \ - using default '{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(|| { - let valid = valid_options_list(OUTLINE_ORDER_OPTIONS); - let default = default_option_name(OUTLINE_ORDER_OPTIONS); - warnings.push(format!( - "workon.review.outline.order = '{o}' unrecognized (valid: {valid}); \ - using default '{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(|| { - let valid = valid_options_list(ICON_MODE_OPTIONS); - let default = default_option_name(ICON_MODE_OPTIONS); - warnings.push(format!( - "workon.review.icons = '{i}' unrecognized (valid: {valid}); \ - using default '{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(|| { - let valid = valid_options_list(DIFF_LAYOUT_OPTIONS); - let default = default_option_name(DIFF_LAYOUT_OPTIONS); - warnings.push(format!( - "workon.review.diff.layout = '{l}' unrecognized (valid: {valid}); \ - using default '{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(|| { - let valid = valid_options_list(DIFF_ZOOM_OPTIONS); - let default = default_option_name(DIFF_ZOOM_OPTIONS); - warnings.push(format!( - "workon.review.diff.zoom = '{z}' unrecognized (valid: {valid}); \ - using default '{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) => parse_diff_text(t).unwrap_or_else(|| { - let valid = valid_options_list(DIFF_TEXT_OPTIONS); - let default = default_option_name(DIFF_TEXT_OPTIONS); - warnings.push(format!( - "workon.review.diff.text = '{t}' unrecognized (valid: {valid}); \ - using default '{default}'" - )); - DiffTextMode::default() - }), + Some(t) => resolve_option( + "workon.review.diff.text", + t, + DIFF_TEXT_OPTIONS, + &mut warnings, + ), None => DiffTextMode::default(), }; self.set_diff_text(diff_text); diff --git a/git-workon-review/src/icons.rs b/git-workon-review/src/icons.rs index aa68c6c..086a9ae 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/outline.rs b/git-workon-review/src/outline.rs index b8de3c7..349401c 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 { From a82d84f536b7594488b3e4f0e69b3ef1b1780bb3 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 23 Jul 2026 17:59:10 -0400 Subject: [PATCH 183/203] feat(review): add one-line prompt input primitive --- git-workon-review/src/lib.rs | 1 + git-workon-review/src/prompt.rs | 508 ++++++++++++++++++++++++++++++++ 2 files changed, 509 insertions(+) create mode 100644 git-workon-review/src/prompt.rs diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 48851b3..44ade65 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -27,6 +27,7 @@ pub mod model; pub mod ops; pub mod outline; pub mod probe_cache; +pub mod prompt; pub mod queue; pub mod refresh; pub mod render; diff --git a/git-workon-review/src/prompt.rs b/git-workon-review/src/prompt.rs new file mode 100644 index 0000000..e6034bd --- /dev/null +++ b/git-workon-review/src/prompt.rs @@ -0,0 +1,508 @@ +//! A one-row text-input primitive: [`PromptState`] holds a buffer plus a byte-offset cursor and +//! exposes pure edit operations (never touches [`crate::app::App`] or terminal I/O — the +//! keymap/cascade wiring that turns key events into these calls, and the pane it renders inside, +//! are a later changeset's job). M11's outline filter (`/` in the outline pane) and diff search +//! (`/` in the diff pane) both need "one editable line with a blinking-cursor feel"; rather than +//! grow that logic twice, this module is that shared line editor, built once and unused until +//! the next two changesets wire it up. +//! +//! Emacs/readline-flavored bindings were chosen over vim-insert-mode ones because the prototype's +//! picker and vim's own cmdline both use them (`Ctrl-a`/`Ctrl-e`/`Ctrl-u`/`Ctrl-w`) — matching +//! muscle memory the target users already have from both source of inspiration. +//! +//! ## Byte offsets, not char counts +//! +//! [`PromptState::cursor`] is a *byte* offset into [`PromptState::buffer`], always sitting on a +//! UTF-8 char boundary — never a char count or a display column. Byte offsets are what +//! `String::insert`/`remove`/slicing want, so every edit op stays a direct buffer mutation with +//! no index translation. Only [`PromptState::render_line`], which has to say "the cursor sits +//! under display column N," walks the buffer accumulating [`unicode_width::UnicodeWidthChar`] +//! widths to translate — the same pattern `render::hscroll_cut` already uses for the same reason +//! (a byte offset and a display column are different units the instant a line has a multibyte or +//! wide character in it). + +use ratatui::style::Modifier; +use ratatui::text::{Line, Span}; +use unicode_width::UnicodeWidthChar; + +/// A single editable line: the text typed so far, and where the cursor sits within it. +/// +/// All mutating methods keep [`Self::cursor`] on a valid UTF-8 char boundary in +/// [`Self::buffer`] (`0..=buffer.len()`) — never mid-codepoint, so every op can slice/insert the +/// buffer directly without a `is_char_boundary` guard at each call site. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PromptState { + buffer: String, + cursor: usize, +} + +impl PromptState { + /// An empty prompt, cursor at the start — the state a filter/search input opens in. + pub fn new() -> Self { + Self::default() + } + + /// The text typed so far. + pub fn buffer(&self) -> &str { + &self.buffer + } + + /// The cursor's byte offset into [`Self::buffer`] (always a char boundary). + pub fn cursor(&self) -> usize { + self.cursor + } + + /// `true` when nothing has been typed — the caller-facing "is there a query at all" check + /// (M11's outline filter/diff search both fall back to their unfiltered/inactive behavior on + /// an empty buffer). + pub fn is_empty(&self) -> bool { + self.buffer.is_empty() + } + + /// Reset to a fresh, empty prompt — `Ctrl-c`'s "clear and defocus" behavior (M11's outline + /// filter) is one call to this plus a focus-flag flip the caller owns. + pub fn clear(&mut self) { + self.buffer.clear(); + self.cursor = 0; + } + + /// Insert `c` at the cursor, then advance the cursor past it. + pub fn insert_char(&mut self, c: char) { + self.buffer.insert(self.cursor, c); + self.cursor += c.len_utf8(); + } + + /// Delete the char immediately before the cursor (readline's `Backspace`) — a no-op at the + /// start of the buffer. + pub fn backspace(&mut self) { + let Some(prev) = self.prev_boundary() else { + return; + }; + self.buffer.drain(prev..self.cursor); + self.cursor = prev; + } + + /// Delete the char immediately after the cursor (readline's `Delete`/`Ctrl-d`) — a no-op at + /// the end of the buffer. + pub fn delete(&mut self) { + let Some(next) = self.next_boundary() else { + return; + }; + self.buffer.drain(self.cursor..next); + } + + /// Move the cursor one char left — a no-op at the start of the buffer. + pub fn move_left(&mut self) { + if let Some(prev) = self.prev_boundary() { + self.cursor = prev; + } + } + + /// Move the cursor one char right — a no-op at the end of the buffer. + pub fn move_right(&mut self) { + if let Some(next) = self.next_boundary() { + self.cursor = next; + } + } + + /// `Ctrl-a`: jump to the start of the buffer. + pub fn move_home(&mut self) { + self.cursor = 0; + } + + /// `Ctrl-e`: jump to the end of the buffer. + pub fn move_end(&mut self) { + self.cursor = self.buffer.len(); + } + + /// `Ctrl-u`: delete everything from the start of the buffer up to (not including) the + /// cursor, then leave the cursor at the (now empty) start — readline's "clear to start of + /// line," not a full [`Self::clear`] (text after the cursor survives). + pub fn clear_to_start(&mut self) { + self.buffer.drain(..self.cursor); + self.cursor = 0; + } + + /// `Ctrl-w`: delete the "word" immediately before the cursor — readline/shell semantics: + /// first skip any run of trailing whitespace, then delete the run of non-whitespace before + /// that. A no-op at the start of the buffer. + pub fn delete_word_back(&mut self) { + if self.cursor == 0 { + return; + } + let before = &self.buffer[..self.cursor]; + let mut end = self.cursor; + let mut chars = before.char_indices().rev().peekable(); + + // Skip trailing whitespace first, so `"foo bar "` + `Ctrl-w` deletes `"bar "`, not just + // the trailing space (matches bash/readline, not a naive "delete back to whitespace"). + while let Some(&(i, c)) = chars.peek() { + if c.is_whitespace() { + end = i; + chars.next(); + } else { + break; + } + } + let mut start = end; + while let Some(&(i, c)) = chars.peek() { + if c.is_whitespace() { + break; + } + start = i; + chars.next(); + } + + self.buffer.drain(start..self.cursor); + self.cursor = start; + } + + /// The byte offset of the char boundary immediately before the cursor, or `None` at the + /// start of the buffer. + fn prev_boundary(&self) -> Option { + self.buffer[..self.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + } + + /// The byte offset of the char boundary immediately after the cursor, or `None` at the end + /// of the buffer. + fn next_boundary(&self) -> Option { + self.buffer[self.cursor..] + .chars() + .next() + .map(|c| self.cursor + c.len_utf8()) + } + + /// Render this prompt as a single [`Line`]: the buffer's text plus a visible cursor cell + /// (styled with [`Modifier::REVERSED`], a block-cursor look with no palette dependency — the + /// pane that ends up hosting this decides surrounding chrome/colors, so this stays as + /// theme-agnostic as `render::hscroll_cut`'s column math it borrows). When the cursor sits + /// past the last char (the common case — typing appends at the end), the cell is a single + /// reversed space so the cursor is still visible on an otherwise-plain trailing position. + /// + /// Splits the buffer at the cursor's char boundary rather than indexing by display column — + /// the cursor's OWN cell always covers exactly one char (or the trailing space), so no + /// column translation is needed here at all; [`unicode_width`] only matters if a caller later + /// needs to reason about the rendered line's total display width, which this helper doesn't + /// do. + pub fn render_line(&self) -> Line<'static> { + let before = self.buffer[..self.cursor].to_string(); + let mut spans = Vec::with_capacity(3); + if !before.is_empty() { + spans.push(Span::raw(before)); + } + + match self.next_boundary() { + Some(next) => { + spans.push(Span::styled( + self.buffer[self.cursor..next].to_string(), + Modifier::REVERSED, + )); + let after = &self.buffer[next..]; + if !after.is_empty() { + spans.push(Span::raw(after.to_string())); + } + } + None => spans.push(Span::styled(" ".to_string(), Modifier::REVERSED)), + } + + Line::from(spans) + } + + /// The cursor's 0-based display column (unicode-width aware) — for a caller that needs to + /// place a terminal cursor or align adjacent chrome rather than just render the line via + /// [`Self::render_line`] (which needs no column math of its own; see that method's doc + /// comment). + pub fn cursor_col(&self) -> usize { + self.buffer[..self.cursor] + .chars() + .map(|c| UnicodeWidthChar::width(c).unwrap_or(0)) + .sum() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_appends_and_advances_cursor() { + let mut p = PromptState::new(); + p.insert_char('a'); + p.insert_char('b'); + assert_eq!(p.buffer(), "ab"); + assert_eq!(p.cursor(), 2); + } + + #[test] + fn insert_in_the_middle_shifts_the_tail() { + let mut p = PromptState::new(); + for c in "ac".chars() { + p.insert_char(c); + } + p.move_left(); + p.insert_char('b'); + assert_eq!(p.buffer(), "abc"); + assert_eq!(p.cursor(), 2); + } + + #[test] + fn backspace_at_start_is_a_noop() { + let mut p = PromptState::new(); + p.backspace(); + assert_eq!(p.buffer(), ""); + assert_eq!(p.cursor(), 0); + } + + #[test] + fn backspace_deletes_the_char_before_the_cursor() { + let mut p = PromptState::new(); + for c in "abc".chars() { + p.insert_char(c); + } + p.backspace(); + assert_eq!(p.buffer(), "ab"); + assert_eq!(p.cursor(), 2); + } + + #[test] + fn delete_at_end_is_a_noop() { + let mut p = PromptState::new(); + for c in "abc".chars() { + p.insert_char(c); + } + p.delete(); + assert_eq!(p.buffer(), "abc"); + assert_eq!(p.cursor(), 3); + } + + #[test] + fn delete_removes_the_char_after_the_cursor() { + let mut p = PromptState::new(); + for c in "abc".chars() { + p.insert_char(c); + } + p.move_home(); + p.delete(); + assert_eq!(p.buffer(), "bc"); + assert_eq!(p.cursor(), 0); + } + + #[test] + fn move_left_and_right_clamp_at_the_edges() { + let mut p = PromptState::new(); + p.insert_char('a'); + p.move_right(); + assert_eq!(p.cursor(), 1, "move_right must not run past the end"); + p.move_left(); + p.move_left(); + assert_eq!(p.cursor(), 0, "move_left must not run past the start"); + } + + #[test] + fn home_and_end_jump_to_the_buffer_edges() { + let mut p = PromptState::new(); + for c in "hello".chars() { + p.insert_char(c); + } + p.move_home(); + assert_eq!(p.cursor(), 0); + p.move_end(); + assert_eq!(p.cursor(), 5); + } + + #[test] + fn clear_to_start_drops_the_prefix_and_keeps_the_suffix() { + let mut p = PromptState::new(); + for c in "hello world".chars() { + p.insert_char(c); + } + for _ in 0.."world".len() { + p.move_left(); + } + p.clear_to_start(); + assert_eq!(p.buffer(), "world"); + assert_eq!(p.cursor(), 0); + } + + #[test] + fn clear_resets_buffer_and_cursor() { + let mut p = PromptState::new(); + for c in "hello".chars() { + p.insert_char(c); + } + p.clear(); + assert_eq!(p.buffer(), ""); + assert_eq!(p.cursor(), 0); + assert!(p.is_empty()); + } + + #[test] + fn delete_word_back_deletes_the_trailing_word() { + let mut p = PromptState::new(); + for c in "foo bar".chars() { + p.insert_char(c); + } + p.delete_word_back(); + assert_eq!(p.buffer(), "foo "); + assert_eq!(p.cursor(), 4); + } + + #[test] + fn delete_word_back_skips_trailing_whitespace_first() { + let mut p = PromptState::new(); + for c in "foo bar ".chars() { + p.insert_char(c); + } + p.delete_word_back(); + assert_eq!( + p.buffer(), + "foo ", + "Ctrl-w on trailing whitespace should delete the word AND the whitespace, \ + matching readline" + ); + assert_eq!(p.cursor(), 4); + } + + #[test] + fn delete_word_back_at_start_is_a_noop() { + let mut p = PromptState::new(); + p.delete_word_back(); + assert_eq!(p.buffer(), ""); + } + + #[test] + fn delete_word_back_from_a_single_word_clears_the_buffer() { + let mut p = PromptState::new(); + for c in "hello".chars() { + p.insert_char(c); + } + p.delete_word_back(); + assert_eq!(p.buffer(), ""); + assert_eq!(p.cursor(), 0); + } + + #[test] + fn cursor_clamps_to_char_boundaries_with_multibyte_text() { + let mut p = PromptState::new(); + for c in "héllo".chars() { + p.insert_char(c); + } + // "h" (1 byte) + "é" (2 bytes) = cursor should land at byte 3 after two inserts, and + // every subsequent move must keep landing on a char boundary (a panic here would mean + // `move_left`/`move_right` walked into the middle of "é"'s 2-byte encoding). + p.move_home(); + p.move_right(); + assert_eq!( + p.cursor(), + 1, + "cursor should sit right after the 1-byte 'h'" + ); + p.move_right(); + assert_eq!( + p.cursor(), + 3, + "cursor should skip clean over 'é' (2 bytes), landing at byte 3" + ); + } + + #[test] + fn backspace_removes_one_whole_multibyte_char() { + let mut p = PromptState::new(); + for c in "hé".chars() { + p.insert_char(c); + } + p.backspace(); + assert_eq!(p.buffer(), "h"); + assert_eq!(p.cursor(), 1); + } + + #[test] + fn cursor_col_counts_wide_cjk_chars_as_two_columns() { + let mut p = PromptState::new(); + for c in "漢字".chars() { + p.insert_char(c); + } + assert_eq!( + p.cursor_col(), + 4, + "two double-width chars should occupy 4 display columns" + ); + } + + #[test] + fn cursor_col_counts_narrow_accented_chars_as_one_column() { + let mut p = PromptState::new(); + for c in "héllo".chars() { + p.insert_char(c); + } + assert_eq!(p.cursor_col(), 5, "'héllo' is 5 narrow chars wide"); + } + + #[test] + fn render_line_places_the_cursor_span_at_the_end_when_typing() { + let mut p = PromptState::new(); + for c in "ab".chars() { + p.insert_char(c); + } + let line = p.render_line(); + // "ab" followed by a trailing reversed-space cursor cell: two spans, not three. + assert_eq!(line.spans.len(), 2); + assert_eq!(line.spans[0].content, "ab"); + assert_eq!(line.spans[1].content, " "); + assert!(line.spans[1] + .style + .add_modifier + .contains(Modifier::REVERSED)); + } + + #[test] + fn render_line_wraps_the_char_under_the_cursor_when_not_at_the_end() { + let mut p = PromptState::new(); + for c in "abc".chars() { + p.insert_char(c); + } + p.move_home(); + p.move_right(); + let line = p.render_line(); + assert_eq!(line.spans.len(), 3); + assert_eq!(line.spans[0].content, "a"); + assert_eq!(line.spans[1].content, "b"); + assert!(line.spans[1] + .style + .add_modifier + .contains(Modifier::REVERSED)); + assert_eq!(line.spans[2].content, "c"); + } + + #[test] + fn render_line_on_an_empty_prompt_is_a_single_cursor_cell() { + let p = PromptState::new(); + let line = p.render_line(); + assert_eq!(line.spans.len(), 1); + assert_eq!(line.spans[0].content, " "); + assert!(line.spans[0] + .style + .add_modifier + .contains(Modifier::REVERSED)); + } + + #[test] + fn render_line_wraps_a_wide_cjk_char_under_the_cursor() { + let mut p = PromptState::new(); + for c in "a漢b".chars() { + p.insert_char(c); + } + p.move_home(); + p.move_right(); + let line = p.render_line(); + assert_eq!(line.spans.len(), 3); + assert_eq!(line.spans[1].content, "漢"); + assert!(line.spans[1] + .style + .add_modifier + .contains(Modifier::REVERSED)); + } +} From ae6cdad5793a95f72fb993f76c3d19610bc8bdfa Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 23 Jul 2026 18:21:53 -0400 Subject: [PATCH 184/203] feat(review): add fuzzy filter to the outline pane --- Cargo.lock | 1 + git-workon-review/Cargo.toml | 1 + git-workon-review/src/app.rs | 420 ++++++++++++++++++++++++++++--- git-workon-review/src/keymap.rs | 8 + git-workon-review/src/outline.rs | 243 ++++++++++++++++++ git-workon-review/src/render.rs | 260 ++++++++++++++++--- git-workon-review/src/tui.rs | 334 ++++++++++++++++++++++-- 7 files changed, 1178 insertions(+), 89 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3e14e3..a3748ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -976,6 +976,7 @@ dependencies = [ "devicons", "dirs", "expectrl", + "fuzzy-matcher", "git-workon-fixture", "git-workon-lib", "git2", diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index ce464c5..35d16e6 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -37,6 +37,7 @@ clap_complete.workspace = true crossterm.workspace = true devicons.workspace = true dirs.workspace = true +fuzzy-matcher.workspace = true git-workon-lib.workspace = true git2.workspace = true libc.workspace = true diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index d458e67..96d2fdb 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -30,6 +30,7 @@ use crate::ops; use crate::outline::{ self, FoldKey, OutlineChangeset, OutlineFile, OutlineItem, OutlineMode, OutlineOrder, }; +use crate::prompt::PromptState; use crate::queue::{OpOutcome, StagingOp, StagingQueue}; use crate::refresh::{IndexSignature, RefreshCoordinator}; use crate::scope::enclosing_scope_lines; @@ -965,6 +966,19 @@ pub struct OutlineState { /// outlives its own toggling row's disappearance and reappearance (e.g. a discard-then-recreate /// of the same path) for as long as the session runs. pub folds: HashMap>, + /// CS2 (`outline-filter`, M11): the fuzzy-filter query, `/` while the outline has focus opens. + /// Read fresh every [`App::outline_items`] call (via [`outline::apply_filter`]) rather than + /// cached — persistence across a rebuild (staging op, mode cycle, refresh) is therefore free: + /// the query itself just sits here untouched by any of those, so the very next + /// [`App::outline_items`] call re-derives the same filtered view from the fresh row list. See + /// [`Self::filter_focused`] for the two-focus model this pairs with. + pub filter: PromptState, + /// Whether the one-row filter input (not the outline row list) currently has keyboard capture + /// — the prototype's two-focus model (locked design #2 in the M11 plan): `/` sets this `true`; + /// `Enter`/`Esc` set it back to `false` while KEEPING [`Self::filter`]'s query; `Ctrl-c` clears + /// the query AND sets this `false`. Meaningless unless [`OutlineState::focused`] is also + /// `true` — the filter input can't have keyboard capture while the diff pane does. + pub filter_focused: bool, } /// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the @@ -1563,6 +1577,8 @@ impl App { order: OutlineOrder::default(), hscroll: 0, folds: HashMap::new(), + filter: PromptState::new(), + filter_focused: false, }; let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial @@ -2695,37 +2711,12 @@ impl App { .collect() } - /// Build (via [`outline::fold_outline`]) the current [`OutlineMode`]'s FOLD-FILTERED row list - /// — the outline cursor's SINGLE index space, and the source of truth every other outline - /// consumer reads: `render.rs`, [`Self::outline_move_by`]/[`Self::outline_move_to`], - /// [`Self::outline_confirm`], [`Self::summary_target`], and the staging-verb resolution in - /// [`Self::outline_row_targets`] all funnel through this SAME method (CS5, `outline-fold`) — - /// so folding a Header/Dir can never silently retarget a cursor move or a stage/discard verb - /// onto the wrong row: there is no OTHER row list any of them could accidentally read - /// instead. Rebuilt fresh on every call (cheap: a small stack times a handful of files each, - /// no caching, same posture as [`Self::effective_zoom_for`]) rather than cached on `App`, so - /// it's never stale across a mode toggle, a nav, a fold, or a refresh. `render.rs`'s marker - /// needs the per-row hidden-file counts this discards — see - /// [`Self::outline_items_with_hidden_counts`]. - pub fn outline_items(&self) -> Vec { - self.outline_folded().items - } - - /// [`Self::outline_items`], plus (aligned by index) each row's CS5 hidden-file marker count — - /// `render_outline`'s data source. Every OTHER outline consumer uses [`Self::outline_items`] - /// instead, which just discards the counts it doesn't need; both funnel through the same - /// [`Self::outline_folded`] build, so they can never disagree about which rows are visible. - pub fn outline_items_with_hidden_counts(&self) -> (Vec, Vec) { - let folded = self.outline_folded(); - (folded.items, folded.hidden_counts) - } - - /// The shared build [`Self::outline_items`]/[`Self::outline_items_with_hidden_counts`]/ - /// [`Self::outline_target_index`] all read from — [`outline::fold_outline`] applied to the - /// current mode/order/fold-set, so there's exactly one place that pairs "which changesets by - /// which state" with "the fold set for the CURRENT mode" (`self.outline.folds` is keyed by - /// [`OutlineMode`]; a mode with no folds recorded yet reads as "everything expanded", the - /// default). + /// The current [`OutlineMode`]'s FOLD-FILTERED row list — [`Self::outline_items`]'s FOLD-ONLY + /// input, before CS2's fuzzy filter (if any) is layered on top. `render.rs`'s marker needs the + /// per-row hidden-file counts this alone carries — see [`Self::outline_items_with_hidden_counts`]. + /// Rebuilt fresh on every call (cheap: a small stack times a handful of files each, no + /// caching, same posture as [`Self::effective_zoom_for`]) rather than cached on `App`, so it's + /// never stale across a mode toggle, a nav, a fold, or a refresh. fn outline_folded(&self) -> outline::FoldedOutline { let snapshot = self.outline_snapshot(); let folds = self.outline.folds.get(&self.outline.mode); @@ -2734,15 +2725,87 @@ impl App { }) } - /// Resolve a target row matched against the FULL (unfiltered) row list to its position in - /// [`Self::outline_items`]'s FILTERED list — its own index if it's visible, or its nearest - /// visible (collapsed) ancestor's if a fold hides it (CS5's "`sync_outline_to_current` - /// targeting a file hidden under a collapsed node lands on the collapsed ancestor WITHOUT - /// auto-expanding" rule — see [`outline::FoldedOutline::visible_index`]'s doc comment). `find` - /// matches against the full build (via `outline::build_items` directly, not - /// [`Self::outline_items`]) since a fold-hidden target has no index in the filtered list at - /// all to match against. + /// [`Self::outline_folded`] with CS2's fuzzy filter layered on top when + /// [`OutlineState::filter`] holds a query — the outline cursor's SINGLE index space, and the + /// source of truth every other outline consumer reads: `render.rs`, + /// [`Self::outline_move_by`]/[`Self::outline_move_to`], [`Self::outline_confirm`], + /// [`Self::summary_target`], and the staging-verb resolution in [`Self::outline_row_targets`] + /// all funnel through [`Self::outline_items`]/[`Self::outline_items_with_hidden_counts`] below + /// — so an active filter can never silently retarget a cursor move or a stage/discard verb + /// onto a row the filter itself hid. + /// + /// An empty query returns [`Self::outline_folded`] UNCHANGED (zero regression when the filter + /// is unused — no [`outline::apply_filter`] call at all, so hidden-file markers and fold + /// structure render exactly as before this changeset). A non-empty query instead runs + /// [`outline::apply_filter`] over the fold-filtered rows and returns its flat, score-ordered + /// result with every hidden-file marker zeroed (a filtered row is never itself collapsed — + /// [`outline::apply_filter`] already reset its `guides`, and a chevron marker referring to + /// counts computed against the PRE-filter row list would be meaningless against this one) and + /// an identity `visible_index` (every surviving row maps onto its own position — nothing else + /// reads a filtered build's `visible_index`, since [`Self::outline_target_index`] special-cases + /// the filtered case instead of using it). + fn outline_filtered(&self) -> outline::FoldedOutline { + let folded = self.outline_folded(); + if self.outline.filter.is_empty() { + return folded; + } + let filtered = outline::apply_filter(&folded.items, self.outline.filter.buffer()); + let hidden_counts = vec![0; filtered.items.len()]; + let visible_index = (0..filtered.items.len()).collect(); + outline::FoldedOutline { + items: filtered.items, + hidden_counts, + visible_index, + } + } + + /// [`Self::outline_filtered`]'s row list — see that method's doc comment for the fold+filter + /// composition, and [`Self::outline_items_with_hidden_counts`] for the render-facing variant + /// that also carries fold markers and match indices. + pub fn outline_items(&self) -> Vec { + self.outline_filtered().items + } + + /// [`Self::outline_items`], plus (aligned by index) each row's CS5 hidden-file marker count + /// and CS2's fuzzy-match char indices (empty when no filter is active, or for a row the query + /// didn't highlight any char of) — `render_outline`'s data source. Every OTHER outline + /// consumer uses [`Self::outline_items`] instead, which just discards what it doesn't need; + /// both funnel through the same [`Self::outline_filtered`]/[`Self::outline_folded`] build, so + /// they can never disagree about which rows are visible. + pub fn outline_items_with_hidden_counts( + &self, + ) -> (Vec, Vec, Vec>) { + let folded = self.outline_folded(); + if self.outline.filter.is_empty() { + let match_indices = vec![Vec::new(); folded.items.len()]; + return (folded.items, folded.hidden_counts, match_indices); + } + let filtered = outline::apply_filter(&folded.items, self.outline.filter.buffer()); + let hidden_counts = vec![0; filtered.items.len()]; + (filtered.items, hidden_counts, filtered.match_indices) + } + + /// Resolve a target row matched against the FULL (unfiltered, unfolded) row list to its + /// position in [`Self::outline_items`]'s row list. + /// + /// With NO CS2 fuzzy filter active: its own index if it's visible, or its nearest visible + /// (collapsed) ancestor's if a fold hides it (CS5's "`sync_outline_to_current` targeting a + /// file hidden under a collapsed node lands on the collapsed ancestor WITHOUT auto-expanding" + /// rule — see [`outline::FoldedOutline::visible_index`]'s doc comment). `find` matches against + /// the full build (via `outline::build_items` directly, not [`Self::outline_items`]) since a + /// fold-hidden target has no index in the fold-filtered list at all to match against. + /// + /// With a CS2 fuzzy filter active: `None` when the target row's own text didn't survive the + /// filter — a flat, re-ordered, re-scored filtered list has no ancestor-fallback story the way + /// a fold does (the locked design's "parents of matched children are NOT preserved" rule), so + /// there is genuinely no row to land `find`'s target on. Callers (currently only + /// [`Self::sync_outline_to_current`]) already treat `None` as "leave the cursor where it is, + /// clamped" — precisely the CS2 gotcha's "no-op instead of clearing the filter" requirement, + /// since neither branch here ever touches [`OutlineState::filter`] itself. fn outline_target_index(&self, find: impl Fn(&OutlineItem) -> bool) -> Option { + if !self.outline.filter.is_empty() { + return self.outline_items().iter().position(find); + } let snapshot = self.outline_snapshot(); let full = outline::build_items(&snapshot, self.outline.mode, self.outline.order); let full_idx = full.iter().position(find)?; @@ -2831,6 +2894,33 @@ impl App { self.outline.cursor } + /// CS2 (`outline-filter`): the current filter query, for `render.rs`'s input-row line. + pub fn outline_filter_query(&self) -> &str { + self.outline.filter.buffer() + } + + /// CS2: the filter input's own [`PromptState`] — `render.rs` calls + /// [`PromptState::render_line`] on it directly rather than `app.rs` doing so itself, keeping + /// this module free of a `ratatui` dependency (see [`Region`]'s doc comment for the same + /// discipline elsewhere in this file). + pub fn outline_filter_state(&self) -> &PromptState { + &self.outline.filter + } + + /// CS2: whether the filter INPUT ROW (not the outline row list) currently has keyboard + /// capture — see [`OutlineState::filter_focused`]'s doc comment for the two-focus model. + pub fn outline_filter_focused(&self) -> bool { + self.outline.filter_focused + } + + /// CS2: whether `render_outline` should paint the filter input row at all — non-empty query + /// OR input-focused (locked design: typing shows the row; leaving it focused with an empty + /// query still shows it, so the cursor has somewhere to render). `false` (the pre-CS2 default) + /// renders the outline exactly as before this changeset. + pub fn outline_filter_active(&self) -> bool { + self.outline.filter_focused || !self.outline.filter.is_empty() + } + /// Top-of-viewport row index into [`Self::outline_items`]'s row list — see /// [`Self::derive_outline_scroll`]. pub fn outline_scroll(&self) -> usize { @@ -3337,6 +3427,104 @@ impl App { self.sync_outline_to_current(); } + // ── Outline fuzzy filter (CS2 `outline-filter`, M11) ───────────────────────── + + /// `/` while the outline has focus: give the filter input row keyboard capture. The keymap + /// only ever dispatches this while [`OutlineState::focused`] is already `true` (it's a + /// [`crate::config::View::Outline`]-namespaced command), so this never has to flip that flag + /// itself. Leaves any existing query untouched — re-pressing `/` on an already-active filter + /// just returns capture to it rather than resetting anything. + pub fn outline_filter_focus(&mut self) { + self.outline.filter_focused = true; + } + + /// `Enter`/`Esc` while the filter input is focused: hand keyboard capture back to the outline + /// row LIST, KEEPING the query — the locked two-focus model (`Ctrl-c` below is the only path + /// that clears it). The row list itself needs no re-derivation here: it already reads + /// [`OutlineState::filter`] live on every [`Self::outline_items`] call, so nothing about the + /// visible rows changes just because capture moves off the input row. + pub fn outline_filter_unfocus(&mut self) { + self.outline.filter_focused = false; + } + + /// `Ctrl-c` while the filter input is focused: clear the query AND hand capture back to the + /// list — the one path that discards the filter entirely, unlike [`Self::outline_filter_unfocus`]. + /// Re-syncs the cursor via [`Self::sync_outline_to_current`] (mirroring + /// [`Self::outline_collapse_all`]/[`Self::outline_expand_all`]'s reseat) since clearing the + /// query can radically reshape the row list (from a short filtered set back to the full + /// fold-filtered one). + pub fn outline_filter_clear(&mut self) { + self.outline.filter.clear(); + self.outline.filter_focused = false; + self.sync_outline_to_current(); + } + + /// After any filter-input edit that can change the QUERY TEXT (so the matched/scored row set + /// itself just reshaped, not merely the cursor's position within a stable list): reseat the + /// outline cursor to the top-scored row — mirroring a picker reopening its list on every + /// keystroke — and re-derive the scroll. `render_body`'s summary-vs-diff branch + /// ([`Self::summary_target`]) and `outline_move_by`'s own switch-on-landing behavior both key + /// off `outline.cursor`, so parking it at `0` (the new best match) rather than leaving it at a + /// stale index is what makes typing feel live rather than leaving the cursor pointing at + /// whatever row happened to still be there. + fn outline_filter_reflow(&mut self) { + self.outline.cursor = 0; + self.derive_outline_scroll(self.outline_items().len()); + } + + /// Insert one typed char into the filter query (every non-control, non-Alt `Char` key while + /// the filter input is focused). + pub fn outline_filter_insert_char(&mut self, c: char) { + self.outline.filter.insert_char(c); + self.outline_filter_reflow(); + } + + /// `Backspace` while the filter input is focused. + pub fn outline_filter_backspace(&mut self) { + self.outline.filter.backspace(); + self.outline_filter_reflow(); + } + + /// `Delete` while the filter input is focused. + pub fn outline_filter_delete(&mut self) { + self.outline.filter.delete(); + self.outline_filter_reflow(); + } + + /// `Left` while the filter input is focused — moves the INPUT's own cursor, not the outline + /// selection (`Down`/`Up`/`Ctrl-n`/`Ctrl-p` do that instead — see `tui::update`'s filter-input + /// capture arm). Doesn't reshape the row list, so no [`Self::outline_filter_reflow`]. + pub fn outline_filter_move_left(&mut self) { + self.outline.filter.move_left(); + } + + /// `Right` while the filter input is focused — see [`Self::outline_filter_move_left`]. + pub fn outline_filter_move_right(&mut self) { + self.outline.filter.move_right(); + } + + /// `Home`/`Ctrl-a` while the filter input is focused. + pub fn outline_filter_move_home(&mut self) { + self.outline.filter.move_home(); + } + + /// `End`/`Ctrl-e` while the filter input is focused. + pub fn outline_filter_move_end(&mut self) { + self.outline.filter.move_end(); + } + + /// `Ctrl-u` while the filter input is focused. + pub fn outline_filter_clear_to_start(&mut self) { + self.outline.filter.clear_to_start(); + self.outline_filter_reflow(); + } + + /// `Ctrl-w` while the filter input is focused. + pub fn outline_filter_delete_word_back(&mut self) { + self.outline.filter.delete_word_back(); + self.outline_filter_reflow(); + } + // ── Outline staging (CS7) ─────────────────────────────────────────────────── /// Whether the changeset at `cs_idx` is a committed range rather than the uncommitted @@ -12472,4 +12660,158 @@ mod tests { assert_eq!(app.outline_cursor(), outline_cursor_before); assert_eq!((app.current_cs(), app.current), current_before); } + + // ── CS2 (`outline-filter`, M11): fuzzy filter ───────────────────────────────── + + #[test] + fn outline_items_applies_the_active_filter_and_keeps_true_indices() { + let mut app = two_committed_changesets_two_and_one_files(); + open_focused_outline(&mut app, OutlineMode::Stack, 0); + assert_eq!( + app.outline_items().len(), + 5, + "unfiltered: [Header cs-a, a1.txt, a2.txt, Header cs-b, b1.txt]" + ); + + app.outline_filter_insert_char('b'); + app.outline_filter_insert_char('1'); + + let items = app.outline_items(); + assert_eq!(items.len(), 1, "only b1.txt fuzzy-matches 'b1'"); + assert_eq!( + items[0], + OutlineItem::File { + cs_idx: 1, + file_idx: 0, + path: "b1.txt".to_string(), + status: StagedStatus::None, + change: FileStatus::Added, + guides: Vec::new(), + }, + "the surviving row keeps its TRUE cs_idx/file_idx into App::changesets" + ); + } + + #[test] + fn outline_items_empty_query_reproduces_the_unfiltered_fold_filtered_list() { + let mut app = two_committed_changesets_two_and_one_files(); + open_focused_outline(&mut app, OutlineMode::Stack, 0); + let before = app.outline_items(); + + // Focusing the filter input alone (no query typed) must be a complete no-op on the row + // list — the locked "zero regression when unused" rule. + app.outline_filter_focus(); + + assert_eq!(app.outline_items(), before); + } + + #[test] + fn outline_filter_query_persists_across_a_mode_cycle_and_a_staging_op() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("apple.txt", "a\n", "a\nCHANGED\n") + .unstaged_file("banana.txt", "b\n", "b\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + open_focused_outline(&mut app, OutlineMode::Flat, 0); + app.outline_filter_insert_char('a'); + app.outline_filter_insert_char('p'); + app.outline_filter_insert_char('p'); + assert_eq!(app.outline_filter_query(), "app"); + assert_eq!(app.outline_items().len(), 1, "only apple.txt matches 'app'"); + + // A mode cycle rebuilds the row list from scratch — the query must survive untouched, and + // re-filter the newly-rebuilt list the same way. + app.outline_cycle_mode(); + assert_eq!( + app.outline_filter_query(), + "app", + "a mode cycle must not clear the filter query" + ); + assert_eq!(app.outline_items().len(), 1, "still just apple.txt"); + + // A staging op runs `coordinated_refresh`, which rebuilds `outline_snapshot`/the fold — + // the query must survive that too. + let idx = outline_file_row(&app, "apple.txt"); + app.outline.cursor = idx; + app.outline_stage(); + assert!(app.notice.is_none(), "stage must succeed: {:?}", app.notice); + assert_eq!( + app.outline_filter_query(), + "app", + "a staging op's refresh must not clear the filter query" + ); + } + + #[test] + fn outline_filter_clear_restores_the_full_row_list_and_unfocuses_the_input() { + let mut app = two_committed_changesets_two_and_one_files(); + open_focused_outline(&mut app, OutlineMode::Stack, 0); + let full_len = app.outline_items().len(); + app.outline_filter_focus(); + app.outline_filter_insert_char('b'); + assert!(app.outline_items().len() < full_len); + + app.outline_filter_clear(); + + assert!(app.outline_filter_query().is_empty()); + assert!(!app.outline_filter_focused()); + assert_eq!(app.outline_items().len(), full_len); + } + + #[test] + fn outline_filter_unfocus_keeps_the_query_and_the_narrowed_row_list() { + let mut app = two_committed_changesets_two_and_one_files(); + open_focused_outline(&mut app, OutlineMode::Stack, 0); + app.outline_filter_focus(); + app.outline_filter_insert_char('b'); + let narrowed_len = app.outline_items().len(); + + app.outline_filter_unfocus(); + + assert!(!app.outline_filter_focused()); + assert_eq!(app.outline_filter_query(), "b"); + assert_eq!(app.outline_items().len(), narrowed_len); + } + + #[test] + fn sync_outline_to_current_no_ops_the_cursor_when_the_current_file_is_filtered_out() { + // A file-nav call (`next_file`) triggers `sync_outline_to_current`; with a filter active + // that hides the landing file's own row, the cursor must stay wherever it already was + // (clamped into the filtered list's bounds) rather than the filter being silently cleared + // to make room for a "found" row. + let mut app = two_committed_changesets_two_and_one_files(); + open_focused_outline(&mut app, OutlineMode::Flat, 0); + app.outline_filter_insert_char('a'); // matches a1.txt/a2.txt, not b1.txt + let items = app.outline_items(); + assert!( + items + .iter() + .all(|it| !matches!(it, OutlineItem::File { cs_idx: 1, .. })), + "b1.txt (cs-b) must be filtered out by the 'a' query" + ); + let cursor_before = app.outline_cursor(); + let query_before = app.outline_filter_query().to_string(); + + // Switch the diff's current file to b1.txt (cs-b), which the active filter hides. + // `switch_changeset` itself never calls `sync_outline_to_current` (see that method's own + // doc comment on the sync-follow discipline), so call it directly here — exactly what a + // diff-initiated nav entry point (`next_file`/`refresh`/…) would do next. + app.switch_changeset(1, 0); + app.sync_outline_to_current(); + + assert_eq!( + app.outline_filter_query(), + query_before, + "the filter must never be cleared as a side effect of a sync no-op" + ); + assert_eq!( + app.outline_cursor(), + cursor_before.min(app.outline_items().len().saturating_sub(1)), + "the cursor merely clamps into the filtered list's bounds, exactly like the \ + pre-CS2 fallback for an unresolvable sync target" + ); + } } diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index ec4da2d..dda786b 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -90,6 +90,7 @@ pub enum Command { OutlinePrevChangeset, OutlineCollapseAll, OutlineExpandAll, + OutlineFilter, } /// One row of the action registry: a [`Command`] with its stable config identity (`view` + @@ -450,6 +451,13 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "zR", description: "Expand every changeset/directory in the outline", }, + Registered { + command: Command::OutlineFilter, + view: View::Outline, + name: "filter", + default_keys: "/", + description: "Fuzzy-filter the outline", + }, ]; /// One matchable key press: a [`KeyCode`] plus whether Ctrl/Alt are required. **Shift is diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 349401c..d2b8446 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -24,6 +24,9 @@ use std::collections::HashMap; +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; + use crate::model::FileStatus; /// Which of the outline's row-building strategies is active — cycled by `i` (only while the @@ -453,6 +456,79 @@ pub(crate) fn fold_outline( apply_fold(&items, is_folded) } +// ── Fuzzy filter (CS2 `outline-filter`) ───────────────────────────────────── + +/// `item`'s plain, undecorated text — what CS2's filter scores against and CS-render highlights +/// matched chars within. Deliberately the row's OWN field, never a reconstruction: a +/// [`OutlineItem::File`] row's `path` is already the full path in [`OutlineMode::Flat`]/ +/// [`OutlineMode::Stack`] but just the leaf segment in the tree modes (see [`OutlineItem::File`]'s +/// own doc comment) — the filter matches whatever that row is already carrying, not a +/// mode-independent "always full path" reconstruction, per the locked design's "matches against +/// the plain item text — never the decorated label" rule. +fn filter_text(item: &OutlineItem) -> &str { + match item { + OutlineItem::Header { label, .. } => label, + OutlineItem::Dir { name, .. } => name, + OutlineItem::File { path, .. } => path, + } +} + +/// [`apply_filter`]'s output: the filtered, score-ordered row list, plus (parallel by index) each +/// surviving row's matched CHAR indices into [`filter_text`]'s own string — `render.rs`'s +/// highlight source. `guides` on every row is reset to empty (see [`apply_filter`]'s doc comment +/// on why a filtered result is always a flat list). +#[derive(Debug, Clone)] +pub(crate) struct FilteredOutline { + pub items: Vec, + pub match_indices: Vec>, +} + +/// Score every row in `items` (a fold-filtered build's output) against `query` with +/// [`SkimMatcherV2`], drop non-matches, and order survivors by score descending — ties keep their +/// ORIGINAL relative order ([`Vec::sort_by`] is a stable sort, and the comparator only orders by +/// score, so two equal scores never swap). `cs_idx`/`file_idx` on every surviving [`OutlineItem`] +/// are untouched clones of `items`' own — CS2's "never re-index" invariant. +/// +/// The locked design's "flat filtered list (parents of matched children are NOT preserved)" rule: +/// every surviving row's `guides` is reset to empty, regardless of the source [`OutlineMode`] — +/// a Tree/StackTree row's tree-guide vector describes connectors to ANCESTOR rows that a filtered, +/// re-ordered result no longer necessarily carries alongside it, so drawing them would show +/// dangling/wrong connectors. `render::build_outline_line` already reads an empty `guides` as "flat +/// indent, no tree connectors" for exactly this reason (see [`OutlineItem`]'s own doc comment), so +/// this reuses that existing fallback rather than adding a new render-side branch. +/// +/// `query.is_empty()` is the caller's ("is a filter even active") gate, not this fn's — an empty +/// query here would fuzzy-match every row trivially (a no-op filter with meaningless scores), so +/// callers only invoke this once a query exists (see `App::outline_items`'s doc comment). +pub(crate) fn apply_filter(items: &[OutlineItem], query: &str) -> FilteredOutline { + let matcher = SkimMatcherV2::default(); + let mut scored: Vec<(i64, OutlineItem, Vec)> = items + .iter() + .filter_map(|item| { + let (score, indices) = matcher.fuzzy_indices(filter_text(item), query)?; + let mut flat = item.clone(); + match &mut flat { + OutlineItem::Dir { guides, .. } | OutlineItem::File { guides, .. } => { + guides.clear(); + } + OutlineItem::Header { .. } => {} + } + Some((score, flat, indices)) + }) + .collect(); + scored.sort_by(|a, b| b.0.cmp(&a.0)); + + let mut result = FilteredOutline { + items: Vec::with_capacity(scored.len()), + match_indices: Vec::with_capacity(scored.len()), + }; + for (_, item, indices) in scored { + result.items.push(item); + result.match_indices.push(indices); + } + result +} + /// [`OutlineMode::Stack`]: a header per changeset, then its files in order — no de-duplication, /// every changeset's own copy of a path (if touched more than once across the stack) gets its /// own row under its own header. `order` picks which end of the stack paints first; `cs_idx`/ @@ -1485,4 +1561,171 @@ mod tests { ); assert_eq!(folded.hidden_counts, vec![2]); } + + // ── Fuzzy filter (CS2 `outline-filter`) ───────────────────────────────────── + + #[test] + fn apply_filter_drops_non_matches_and_keeps_true_indices_on_survivors() { + let changesets = vec![cs( + "cs-a", + true, + false, + &[ + ("src/app.rs", StagedStatus::None), + ("README.md", StagedStatus::None), + ], + )]; + let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst); + let filtered = apply_filter(&items, "app"); + assert_eq!( + filtered.items.len(), + 1, + "only src/app.rs fuzzy-matches 'app'; the header and README.md don't" + ); + assert_eq!( + filtered.items[0], + OutlineItem::File { + cs_idx: 0, + file_idx: 0, + path: "src/app.rs".to_string(), + status: StagedStatus::None, + change: FileStatus::Modified, + guides: Vec::new(), + }, + "the surviving row keeps its TRUE cs_idx/file_idx into App::changesets" + ); + } + + #[test] + fn apply_filter_orders_survivors_by_score_descending_stable_on_ties() { + // Two files whose paths are byte-identical apart from a prefix that doesn't affect the + // query's match at all — SkimMatcherV2 scores an exact substring match identically + // regardless of an unrelated prefix, so these two rows tie, and the stable sort must keep + // them in their ORIGINAL (a-before-b) relative order. + let items = vec![ + OutlineItem::File { + cs_idx: 0, + file_idx: 0, + path: "widget.rs".to_string(), + status: StagedStatus::None, + change: FileStatus::Modified, + guides: Vec::new(), + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 1, + path: "widget.rs".to_string(), + status: StagedStatus::None, + change: FileStatus::Modified, + guides: Vec::new(), + }, + ]; + let filtered = apply_filter(&items, "widget"); + assert_eq!(filtered.items.len(), 2); + assert_eq!( + filtered.items, items, + "equal-scoring rows keep their original relative order" + ); + + // A query that scores "app.rs" higher than "src/x/app_helper.rs" (an exact, unbroken + // substring match outranks a scattered one) must sort the exact match first even though + // it appears LATER in the input. + let mixed = vec![ + OutlineItem::File { + cs_idx: 0, + file_idx: 0, + path: "src/x/app_helper.rs".to_string(), + status: StagedStatus::None, + change: FileStatus::Modified, + guides: Vec::new(), + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 1, + path: "app.rs".to_string(), + status: StagedStatus::None, + change: FileStatus::Modified, + guides: Vec::new(), + }, + ]; + let filtered = apply_filter(&mixed, "app.rs"); + assert_eq!( + filtered.items[0], mixed[1], + "the exact substring match ('app.rs') must outrank the scattered one, despite \ + appearing second in the input" + ); + } + + #[test] + fn apply_filter_scores_the_per_mode_plain_text_not_the_decorated_label() { + let changesets = vec![deep_path_changeset("release-widget", true, false)]; + // Header: matches the changeset's label/title. + let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst); + let filtered = apply_filter(&items, "release"); + assert!( + filtered.items.iter().any( + |it| matches!(it, OutlineItem::Header { label, .. } if label == "release-widget") + ), + "a Header row must match against its label" + ); + + // Dir: matches the bare segment name, not the full path. + let tree_items = build_items(&changesets, OutlineMode::Tree, OutlineOrder::HeadFirst); + let filtered = apply_filter(&tree_items, "src"); + assert!( + filtered + .items + .iter() + .any(|it| matches!(it, OutlineItem::Dir { name, .. } if name == "src")), + "a Dir row must match against its own segment name" + ); + + // File: matches the row's own `path` field (leaf-only in tree modes). + let filtered = apply_filter(&tree_items, "top.rs"); + assert!( + filtered + .items + .iter() + .any(|it| matches!(it, OutlineItem::File { path, .. } if path == "top.rs")), + "a File row must match against its own path field" + ); + } + + #[test] + fn apply_filter_resets_guides_to_a_flat_list_even_for_tree_mode_survivors() { + let changesets = vec![deep_path_changeset("cs-a", true, false)]; + let items = build_items(&changesets, OutlineMode::Tree, OutlineOrder::HeadFirst); + let filtered = apply_filter(&items, "b.rs"); + assert_eq!(filtered.items.len(), 1); + match &filtered.items[0] { + OutlineItem::File { guides, path, .. } => { + assert_eq!(path, "b.rs"); + assert!( + guides.is_empty(), + "a filtered result renders flat — no dangling tree connectors to \ + now-absent ancestor rows" + ); + } + other => panic!("expected a File row, got {other:?}"), + } + } + + #[test] + fn apply_filter_match_indices_are_parallel_to_the_surviving_items() { + let items = vec![OutlineItem::File { + cs_idx: 0, + file_idx: 0, + path: "app.rs".to_string(), + status: StagedStatus::None, + change: FileStatus::Modified, + guides: Vec::new(), + }]; + let filtered = apply_filter(&items, "app"); + assert_eq!(filtered.items.len(), filtered.match_indices.len()); + assert_eq!( + filtered.match_indices[0], + vec![0, 1, 2], + "'app' matches the first three chars of 'app.rs' contiguously" + ); + } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index f13060c..91b42c0 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -146,6 +146,11 @@ fn diffstat_spans( /// foreground-bold label with no counter, matching its pre-CS1 appearance exactly. Failed/loading /// markers are still NOT included: the two call sites place them differently (trailing spans on /// the header row vs. a line of their own in the summary). +/// +/// `match_indices` (CS2, `outline-filter`) are CHAR indices into `label` itself — the outline's +/// Header call site passes its row's fuzzy-match indices (empty when no filter is active, or the +/// query didn't match this row); the summary panel's call site always passes `&[]` (it never +/// filters). See [`highlight_filter_match`]'s doc comment for the highlight styling itself. fn changeset_title_spans( label: &str, current: bool, @@ -153,6 +158,7 @@ fn changeset_title_spans( theme: &Palette, icons: IconMode, counter: Option<(usize, usize)>, + match_indices: &[usize], ) -> Vec> { let mut spans = Vec::new(); if current { @@ -180,10 +186,8 @@ fn changeset_title_spans( Style::default().fg(theme.dim), )); } - spans.push(TSpan::styled( - label.to_string(), - Style::default().fg(label_fg).add_modifier(Modifier::BOLD), - )); + let label_style = Style::default().fg(label_fg).add_modifier(Modifier::BOLD); + spans.extend(highlight_filter_match(label, match_indices, label_style)); if needs_restack { spans.push(TSpan::styled( format!(" {}", warn_marker(icons)), @@ -193,6 +197,50 @@ fn changeset_title_spans( spans } +/// CS2 (`outline-filter`): render `text` char-by-char, layering [`Modifier::UNDERLINED`] on top of +/// `base_style` for every char whose index is in `match_indices` (CHAR indices into `text`, from +/// [`fuzzy_matcher::skim::SkimMatcherV2::fuzzy_indices`] via [`crate::outline::apply_filter`]) — +/// reuses the row's own EXISTING foreground/dim color rather than introducing a new theme field: +/// M11's later diff-search slice is what adds dedicated `tint_slot` match-highlight keys (per the +/// plan), so this filter — which CS2 owns start to finish — stays theme-neutral. Groups +/// consecutive matched/unmatched chars into as few spans as possible. `match_indices.is_empty()` +/// (no filter active, or this row wasn't matched — e.g. the summary panel's call site, which never +/// filters) is the common case and returns `text` as a single unstyled-beyond-`base_style` span, +/// so this costs nothing when unused. +fn highlight_filter_match( + text: &str, + match_indices: &[usize], + base_style: Style, +) -> Vec> { + if match_indices.is_empty() { + return vec![TSpan::styled(text.to_string(), base_style)]; + } + let matched: std::collections::HashSet = match_indices.iter().copied().collect(); + let match_style = base_style.add_modifier(Modifier::UNDERLINED); + + let mut spans = Vec::new(); + let mut run = String::new(); + let mut run_matched = false; + for (i, c) in text.chars().enumerate() { + let is_match = matched.contains(&i); + if !run.is_empty() && is_match != run_matched { + spans.push(TSpan::styled( + std::mem::take(&mut run), + if run_matched { match_style } else { base_style }, + )); + } + run_matched = is_match; + run.push(c); + } + if !run.is_empty() { + spans.push(TSpan::styled( + run, + if run_matched { match_style } else { base_style }, + )); + } + spans +} + /// Blend the cursor row's tint into an existing background, so the cursor highlight composites /// with (rather than replaces) del/add/word-diff emphasis on the same row — the row highlight is /// a wash over the whole row, not a mask. `None` (a context/gap cell with no bg span at all) @@ -1021,6 +1069,13 @@ fn render_outline_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palet /// [`App::derive_outline_scroll`] before painting from `app.outline.scroll`, giving the outline /// the same stateful scrolloff-margined viewport the diff panes already have, instead of the old /// transient bottom-anchor scroll computed fresh each frame. +/// +/// CS2 (`outline-filter`, M11) adds a SECOND optional carve-out, below the pane header: a one-row +/// fuzzy-filter input, painted only while [`App::outline_filter_active`] (non-empty query OR the +/// input has capture) — an unused filter leaves every row below exactly where it was before this +/// changeset (the locked "zero regression" rule). A query that matches nothing still shows the +/// (now item-less) outline body with a single dim "no matches" placeholder row rather than a +/// blank pane. fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { // CS1 risk: this `>= 2` guard must exist in BOTH pane renderers (see `render_body`'s matching // carve-out) — a 1-row (or shorter) terminal has no room to spare for a header at all. @@ -1030,9 +1085,15 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) } else { area }; + let area = if app.outline_filter_active() && area.height >= 2 { + render_outline_filter_input(frame, app, area, theme); + 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(); + let (items, hidden_counts, match_indices) = app.outline_items_with_hidden_counts(); // Bounds-clamp only — NOT a cursor-following derive: under the wheel's peek model a // scrolled-away viewport must survive the frame; cursor ops re-derive on their own. app.clamp_outline_scroll(items.len()); @@ -1042,13 +1103,29 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) let scroll = app.outline_scroll(); let icons = app.icon_mode(); + if items.is_empty() && !app.outline_filter_query().is_empty() { + // CS2: the filter matched nothing — a blank pane below the (still-visible) filter input + // reads as broken, so paint an explicit placeholder rather than falling through to the + // loop below (which would render nothing at all, same as any other empty row list). + if area.height > 0 { + let line = Line::from(TSpan::styled( + "No matches".to_string(), + Style::default().fg(theme.dim), + )); + frame + .buffer_mut() + .set_line(area.x, area.y, &line, area.width); + } + return; + } + // Render-side upper clamp of the outline's own pan offset (mirroring `clamp_outline_scroll` // just above) — from EVERY item's built line width, not just the visible rows: outlines are // small (file trees, not file contents), so re-measuring the whole thing here is cheap. let max_line_width = items .iter() .zip(&hidden_counts) - .map(|(item, &hidden)| build_outline_line(item, theme, icons, hidden).width()) + .map(|(item, &hidden)| build_outline_line(item, theme, icons, hidden, &[]).width()) .max() .unwrap_or(0); app.clamp_outline_hscroll(max_line_width); @@ -1062,8 +1139,12 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) continue; }; let hidden = hidden_counts.get(item_idx).copied().unwrap_or(0); + let matches = match_indices + .get(item_idx) + .map(Vec::as_slice) + .unwrap_or(&[]); let is_cursor = item_idx == cursor; - let line = build_outline_line(item, theme, icons, hidden); + let line = build_outline_line(item, theme, icons, hidden, matches); let line = Line::from(pan_spans(line.spans, hscroll, theme)); let line = if is_cursor { apply_cursor_row(line, area.width, theme, focused) @@ -1075,6 +1156,24 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) } } +/// CS2 (`outline-filter`): paint the one-row fuzzy-filter input at `area`'s first row — a leading +/// dim `/` prompt glyph (vim cmdline feel) followed by [`PromptState::render_line`]'s own +/// buffer+cursor spans. Theme-agnostic chrome around a theme-agnostic primitive (see +/// [`crate::prompt`]'s module doc on why `render_line` itself carries no palette dependency) — the +/// `/` glyph is the only themed element here, and it's just `theme.dim`, matching every other +/// quiet-chrome glyph in this module (tree guides, fold markers). +fn render_outline_filter_input(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { + let mut spans = vec![TSpan::styled( + "/".to_string(), + Style::default().fg(theme.dim), + )]; + spans.extend(app.outline_filter_state().render_line().spans); + let line = Line::from(spans); + frame + .buffer_mut() + .set_line(area.x, area.y, &line, area.width); +} + /// Render a tree-guide prefix from an [`OutlineItem::Dir`]/[`OutlineItem::File`] `guides` /// vector: every element but the last draws a continuing `│` (if that ancestor level was NOT /// its parent's last child) or blank space (if it was), and the last element draws the row's own @@ -1202,6 +1301,7 @@ fn build_outline_line( theme: &Palette, icons: IconMode, hidden: usize, + match_indices: &[usize], ) -> Line<'static> { match item { OutlineItem::Header { @@ -1220,6 +1320,7 @@ fn build_outline_line( theme, icons, Some((cs_idx + 1, *n)), + match_indices, ); // ADR-031: a Failed changeset's marker wins over Pending's (a slot is never both, // but Failed is the more actionable state to surface if it somehow were). @@ -1242,13 +1343,18 @@ fn build_outline_line( IconMode::Nerd => format!("{} ", crate::icons::DIR_ICON), IconMode::None => String::new(), }; - let text = format!("{}{icon}{name}/", tree_prefix(guides)); + let dir_style = Style::default() + .fg(theme.dim) + .add_modifier(Modifier::ITALIC); + // CS2 (`outline-filter`): the prefix/icon and trailing slash are never part of the + // fuzzy-matched text (only `name` is — see `outline::filter_text`'s doc comment), so + // only the `name` span runs through `highlight_filter_match`. let mut spans = vec![TSpan::styled( - text, - Style::default() - .fg(theme.dim) - .add_modifier(Modifier::ITALIC), + format!("{}{icon}", tree_prefix(guides)), + dir_style, )]; + spans.extend(highlight_filter_match(name, match_indices, dir_style)); + spans.push(TSpan::styled("/".to_string(), dir_style)); spans.extend(fold_marker(hidden, theme)); Line::from(spans) } @@ -1308,28 +1414,39 @@ fn build_outline_line( // truncation eats the dim dirname before the name a user is scanning for (CS2 // gotcha). Tree/StackTree rows (non-empty `guides`) already carry the path via // ancestor Dir rows, so `path` there is already just the basename — render it as-is. + // + // CS2 (`outline-filter`): `match_indices` are CHAR indices into the WHOLE `path` + // field (see `outline::filter_text`'s doc comment), but the split-path case renders + // `base` FIRST and `dir` SECOND — the reverse of `path`'s own dir-then-base order. + // `dir_chars` re-partitions the indices into each rendered run's OWN local coordinate + // space: an index `< dir_chars` is in `dir` (kept as-is — `dir` is `path`'s own + // prefix); an index `> dir_chars` is in `base`, offset back by `dir_chars + 1` (the + // dropped `/` separator); an index `== dir_chars` (the separator itself, never + // rendered) is dropped from both. + let path_style = Style::default().fg(theme.foreground); if guides.is_empty() { match path.rsplit_once('/') { Some((dir, base)) => { - spans.push(TSpan::styled( - base.to_string(), - Style::default().fg(theme.foreground), - )); - spans.push(TSpan::styled( - format!(" {dir}"), - Style::default().fg(theme.dim), - )); + let dir_chars = dir.chars().count(); + let base_indices: Vec = match_indices + .iter() + .filter(|&&i| i > dir_chars) + .map(|&i| i - dir_chars - 1) + .collect(); + let dir_indices: Vec = match_indices + .iter() + .filter(|&&i| i < dir_chars) + .copied() + .collect(); + spans.extend(highlight_filter_match(base, &base_indices, path_style)); + let dim_style = Style::default().fg(theme.dim); + spans.push(TSpan::styled(" ".to_string(), dim_style)); + spans.extend(highlight_filter_match(dir, &dir_indices, dim_style)); } - None => spans.push(TSpan::styled( - path.clone(), - Style::default().fg(theme.foreground), - )), + None => spans.extend(highlight_filter_match(path, match_indices, path_style)), } } else { - spans.push(TSpan::styled( - path.clone(), - Style::default().fg(theme.foreground), - )); + spans.extend(highlight_filter_match(path, match_indices, path_style)); } Line::from(spans) } @@ -1722,6 +1839,7 @@ fn changeset_summary_lines( theme, icons, None, + &[], ); let mut lines = Vec::new(); @@ -4499,6 +4617,92 @@ mod tests { ); } + // ── CS2 (`outline-filter`, M11): fuzzy filter input row ───────────────────── + + #[test] + fn outline_filter_input_row_renders_only_while_active() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + assert!(app.outline_open()); + assert!( + !app.outline_filter_active(), + "a fresh outline has no active filter" + ); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + assert!( + !content.iter().any(|row| row.starts_with('/')), + "an unused filter must render no input row — the locked zero-regression rule — \ + got:\n{}", + content.join("\n") + ); + + // Focusing the input (even with an empty query) makes it active — the input row must now + // render so the cursor has somewhere to show. + app.outline_filter_focus(); + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + assert!( + content.iter().any(|row| row.starts_with('/')), + "a focused filter input must render its own '/'-prefixed row, got:\n{}", + content.join("\n") + ); + } + + #[test] + fn outline_filter_query_narrows_the_rendered_rows() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.outline_filter_focus(); + app.outline_filter_insert_char('b'); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + assert!( + content.iter().any(|row| row.contains("b.txt")), + "b.txt (cs-b's file) must still render, got:\n{}", + content.join("\n") + ); + assert!( + !content.iter().any(|row| row.contains("a.txt")), + "a.txt must be filtered out by the 'b' query, got:\n{}", + content.join("\n") + ); + } + + #[test] + fn outline_filter_with_no_matches_renders_a_placeholder_row() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.outline_filter_focus(); + for c in "zzzznomatch".chars() { + app.outline_filter_insert_char(c); + } + assert!( + app.outline_items().is_empty(), + "sanity: nothing should match" + ); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + assert!( + content.iter().any(|row| row.contains("No matches")), + "an empty filtered result must show a placeholder row instead of a blank pane, \ + got:\n{}", + content.join("\n") + ); + } + #[test] fn outline_header_current_marker_uses_the_current_color() { let fixture = FixtureBuilder::new() diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 81efab3..8279cc8 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -32,7 +32,7 @@ use std::time::Duration; use crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, - MouseButton, MouseEvent, MouseEventKind, + KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use crossterm::execute; use crossterm::terminal::{ @@ -469,6 +469,7 @@ enum Action { OutlinePrevChangeset, OutlineCollapseAll, OutlineExpandAll, + OutlineFilterFocus, None, } @@ -526,6 +527,7 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::OutlinePrevChangeset => Action::OutlinePrevChangeset, Command::OutlineCollapseAll => Action::OutlineCollapseAll, Command::OutlineExpandAll => Action::OutlineExpandAll, + Command::OutlineFilter => Action::OutlineFilterFocus, } } @@ -672,6 +674,7 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::OutlinePrevChangeset => app.outline_prev_changeset(), Action::OutlineCollapseAll => app.outline_collapse_all(), Action::OutlineExpandAll => app.outline_expand_all(), + Action::OutlineFilterFocus => app.outline_filter_focus(), Action::None => {} } false @@ -691,8 +694,8 @@ enum KeyOutcome { /// documented Esc-precedence cascade, extracted so [`update`] and [`update_batch`] share the exact /// same resolution instead of duplicating it. /// -/// Clears any showing footer notice as a side effect, exactly like `update`'s cases 3-6 do (the -/// confirm/help modals deliberately do not — that stays in their own arms, not here). +/// Clears any showing footer notice as a side effect, exactly like `update`'s cases 4-7 do (the +/// confirm/help/filter-input modals deliberately do not — that stays in their own arms, not here). fn resolve_key( app: &mut App, keymap: &Keymap, @@ -715,6 +718,43 @@ fn resolve_key( )) } +/// `update`'s case-3 modal arm: apply one key press while the CS2 outline-filter INPUT has +/// keyboard capture (see [`App::outline_filter_focused`]). Every branch calls straight into an +/// `App::outline_filter_*` method — no [`Action`]/[`map_key`] indirection, mirroring the +/// confirm/help modals' own direct `key.code` matches just above this arm's call site, rather +/// than routing through the rebindable [`Keymap`] (the filter input's editing keys are readline +/// muscle memory, not a rebindable action set, matching [`crate::prompt`]'s own module doc). +/// +/// Ctrl-modified letters are checked before the plain-`Char` catch-all so `Ctrl-a`/`Ctrl-c`/ +/// `Ctrl-e`/`Ctrl-n`/`Ctrl-p`/`Ctrl-u`/`Ctrl-w` never fall through and get inserted as literal +/// text. `Alt`-modified chars are excluded from the catch-all too (there is no bound behavior for +/// them here, and inserting an Alt-chorded char as plain text would be surprising). +fn apply_filter_input_key(app: &mut App, key: KeyEvent) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Enter | KeyCode::Esc => app.outline_filter_unfocus(), + KeyCode::Char('c') if ctrl => app.outline_filter_clear(), + KeyCode::Char('a') if ctrl => app.outline_filter_move_home(), + KeyCode::Char('e') if ctrl => app.outline_filter_move_end(), + KeyCode::Char('u') if ctrl => app.outline_filter_clear_to_start(), + KeyCode::Char('w') if ctrl => app.outline_filter_delete_word_back(), + KeyCode::Char('n') if ctrl => app.outline_move_by(1), + KeyCode::Char('p') if ctrl => app.outline_move_by(-1), + KeyCode::Char(c) if !ctrl && !key.modifiers.contains(KeyModifiers::ALT) => { + app.outline_filter_insert_char(c); + } + KeyCode::Backspace => app.outline_filter_backspace(), + KeyCode::Delete => app.outline_filter_delete(), + KeyCode::Left => app.outline_filter_move_left(), + KeyCode::Right => app.outline_filter_move_right(), + KeyCode::Home => app.outline_filter_move_home(), + KeyCode::End => app.outline_filter_move_end(), + KeyCode::Down => app.outline_move_by(1), + KeyCode::Up => app.outline_move_by(-1), + _ => {} + } +} + /// Apply one [`AppEvent`] to `app`. Returns `true` when the loop should exit (q/Esc). Resize is a /// no-op — ratatui re-measures `body_area` every frame regardless. Tick drives /// [`App::on_tick`], the M4 index watcher's poll (see the module doc). @@ -724,10 +764,11 @@ fn resolve_key( /// message and performs its normal action. `Resize`/`Tick` do NOT clear it: a redraw or timer /// tick isn't the user acting on the message. /// -/// Esc precedence (highest first): a pending discard confirm > the help overlay being open > an -/// active line selection (diff-focused) > the outline having focus > the diff having focus with -/// the outline open > the normal key map (where Esc quits). Concretely — the home-base model: -/// the outline is where Esc always eventually lands you before it quits. +/// Esc precedence (highest first): a pending discard confirm > the help overlay being open > the +/// CS2 outline-filter input having capture > an active line selection (diff-focused) > the +/// outline having focus > the diff having focus with the outline open > the normal key map (where +/// Esc quits). Concretely — the home-base model: the outline is where Esc always eventually lands +/// you before it quits. /// /// 1. A pending discard confirm captures the keyboard FIRST (before the notice clear and the /// normal key map): `y` accepts, `n`/`Esc` cancels, and every other key is swallowed — a modal @@ -737,21 +778,29 @@ fn resolve_key( /// reacts). Ranked just below the confirm modal — in practice the two are never up /// together, since opening help doesn't run through a confirm, but the confirm winning keeps /// a destructive prompt from ever being silently dismissed by a stray overlay key. -/// 3. Otherwise, with an active line selection AND the diff focused, Esc CANCELS the selection +/// 3. Otherwise, the CS2 outline-filter INPUT (`/`, while it has capture — see +/// [`App::outline_filter_focused`]) captures next, mirroring the same swallow: typing/editing +/// keys reach [`crate::prompt::PromptState`], `Enter`/`Esc` return capture to the outline row +/// list KEEPING the query, `Ctrl-c` clears it and returns capture too, and `Down`/`Up`/ +/// `Ctrl-n`/`Ctrl-p` move the outline selection without leaving the input. Ranked below help +/// (opening help while filtering isn't reachable today — `?` isn't part of the input's own key +/// set — but the ordering still says which would win if that ever changed) and above every +/// other case, since none of them should observe a key the filter input itself consumes. +/// 4. Otherwise, with an active line selection AND the diff focused, Esc CANCELS the selection /// instead of moving focus or quitting (`q` still quits). This arm is guarded to defer to case -/// 4 when the outline has focus (a selection can only be active while looking at the diff, but +/// 5 when the outline has focus (a selection can only be active while looking at the diff, but /// the guard keeps the precedence explicit). Other keys fall through to the normal map — /// `j`/`k` extend the selection, `s`/`d` act on it. -/// 4. Otherwise, while the outline pane has focus, Esc QUITS — same terminal leaf as `q`. The +/// 5. Otherwise, while the outline pane has focus, Esc QUITS — same terminal leaf as `q`. The /// outline is home base; there's nowhere further out to walk to. -/// 5. Otherwise, with the diff focused and the outline OPEN, Esc walks outward one step: it +/// 6. Otherwise, with the diff focused and the outline OPEN, Esc walks outward one step: it /// focuses the outline (same effect as `h`/[`App::focus_outline`]) rather than quitting. -/// 6. Otherwise (diff focused, outline closed) the normal map applies, where Esc (like `q`) quits +/// 7. Otherwise (diff focused, outline closed) the normal map applies, where Esc (like `q`) quits /// — there's no outline to walk out to. /// -/// A `Key` event clears any showing footer notice before applying its own action (cases 3-6); the -/// confirm and help modals (cases 1-2) deliberately do not. Cases 3-6 are delegated to -/// [`resolve_key`], shared with [`update_batch`]. +/// A `Key` event clears any showing footer notice before applying its own action (cases 4-7); the +/// confirm, help, and filter-input modals (cases 1-3) deliberately do not. Cases 4-7 are delegated +/// to [`resolve_key`], shared with [`update_batch`]. fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: AppEvent) -> bool { match event { AppEvent::Key(key) if app.pending_confirm.is_some() => { @@ -771,13 +820,24 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap } false } + AppEvent::Key(key) if app.outline_filter_focused() => { + apply_filter_input_key(app, key); + false + } AppEvent::Key(key) => match resolve_key(app, keymap, pending, key) { KeyOutcome::Handled => false, KeyOutcome::Action(action) => apply_action(app, action), }, - // CS10: both modals swallow mouse input exactly like they swallow keys (cases 1-2 above) - // — a click/wheel while a discard confirm or the help overlay is up does nothing. - AppEvent::Mouse(_) if app.pending_confirm.is_some() || app.help_visible => false, + // CS10: all three modals swallow mouse input exactly like they swallow keys (cases 1-3 + // above) — a click/wheel while a discard confirm, the help overlay, or the CS2 + // outline-filter input is up does nothing. + AppEvent::Mouse(_) + if app.pending_confirm.is_some() + || app.help_visible + || app.outline_filter_focused() => + { + false + } AppEvent::Mouse(m) => { app.clear_notice(); match m.kind { @@ -888,13 +948,16 @@ fn update_batch( for event in events { match event { - // The coalescable path: no modal is up, and this isn't the selection-Esc-cancel - // guard (that guard is a context change — an "Esc cascade" — so it falls to the - // catch-all arm below, which flushes first and delegates the whole event to - // `update`). Notice-clearing still happens per key via `resolve_key`. + // The coalescable path: no modal is up (CS2's outline-filter input included — a key + // while it has capture must reach `apply_filter_input_key` via the catch-all arm's + // `update` delegation below, never `resolve_key`/the coalescing path), and this isn't + // the selection-Esc-cancel guard (that guard is a context change — an "Esc cascade" — + // so it falls to the catch-all arm below, which flushes first and delegates the whole + // event to `update`). Notice-clearing still happens per key via `resolve_key`. AppEvent::Key(key) if app.pending_confirm.is_none() && !app.help_visible + && !app.outline_filter_focused() && !(app.selection_anchor.is_some() && key.code == KeyCode::Esc && !app.outline_focused()) => @@ -3552,4 +3615,231 @@ mod tests { "a ScrollRight event over the diff pane must pan App::hscroll via handle_hwheel" ); } + + // ── CS2 (`outline-filter`, M11): the filter-input modal cascade arm ────────── + + /// A single (uncommitted) changeset with three distinct files, outline open+focused in Flat + /// mode (no header row in the way) — CS2's cascade tests just need "type `/`, then some keys, + /// assert `App` state," and Flat mode keeps the row math simple (every row is a `File`). + fn filter_test_app() -> App { + use git_workon_fixture::prelude::*; + use workon_review::outline::OutlineMode; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("apple.txt", "a\n", "a\nCHANGED\n") + .unstaged_file("banana.txt", "b\n", "b\nCHANGED\n") + .unstaged_file("cherry.txt", "c\n", "c\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.set_outline_mode(OutlineMode::Flat); + app.toggle_outline(); // closed -> open+focused + app + } + + #[test] + fn slash_focuses_the_filter_input_from_the_outline() { + let mut app = filter_test_app(); + assert!( + app.outline_focused(), + "outline must have focus for `/` to dispatch" + ); + assert!(!app.outline_filter_focused()); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Char('/'))), + ); + + assert!( + app.outline_filter_focused(), + "`/` must focus the filter input" + ); + } + + #[test] + fn typing_while_the_filter_is_focused_narrows_the_outline_and_never_falls_through_to_a_bound_key( + ) { + let mut app = filter_test_app(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + app.outline_filter_focus(); + + // 'j' is bound to `cursor-down` in the Outline view — while the filter input has capture + // it must be inserted as literal text instead of moving the outline cursor. + for c in "an".chars() { + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Char(c))), + ); + } + + assert_eq!(app.outline_filter_query(), "an"); + let items = app.outline_items(); + assert_eq!(items.len(), 1, "only banana.txt fuzzy-matches 'an'"); + assert!(matches!( + &items[0], + workon_review::outline::OutlineItem::File { path, .. } if path == "banana.txt" + )); + } + + #[test] + fn enter_returns_focus_to_the_list_and_keeps_the_query() { + let mut app = filter_test_app(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + app.outline_filter_focus(); + app.outline_filter_insert_char('a'); + + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Enter)), + ); + + assert!( + !app.outline_filter_focused(), + "Enter must hand capture back to the list" + ); + assert_eq!(app.outline_filter_query(), "a", "Enter must KEEP the query"); + } + + #[test] + fn esc_returns_focus_to_the_list_and_keeps_the_query_same_as_enter() { + let mut app = filter_test_app(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + app.outline_filter_focus(); + app.outline_filter_insert_char('a'); + + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); + + assert!(!app.outline_filter_focused()); + assert_eq!(app.outline_filter_query(), "a"); + } + + #[test] + fn ctrl_c_clears_the_query_and_returns_focus_to_the_list() { + let mut app = filter_test_app(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + app.outline_filter_focus(); + app.outline_filter_insert_char('a'); + assert!(!app.outline_filter_query().is_empty()); + + update(&mut app, &km, &mut pending, AppEvent::Key(ctrl_key('c'))); + + assert!(!app.outline_filter_focused()); + assert!( + app.outline_filter_query().is_empty(), + "Ctrl-c must clear the query, unlike Enter/Esc" + ); + } + + #[test] + fn down_moves_the_outline_selection_without_leaving_the_filter_input() { + let mut app = filter_test_app(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + app.outline_filter_focus(); + // No query typed: every file row still matches, so all three rows are still reachable to + // move across. + let cursor_before = app.outline_cursor(); + + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Down)), + ); + + assert!( + app.outline_filter_focused(), + "Down must NOT leave the filter input" + ); + assert_eq!( + app.outline_cursor(), + cursor_before + 1, + "Down must move the outline selection while capture stays on the input" + ); + } + + #[test] + fn ctrl_n_and_ctrl_p_also_move_the_outline_selection_from_the_filter_input() { + let mut app = filter_test_app(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + app.outline_filter_focus(); + + update(&mut app, &km, &mut pending, AppEvent::Key(ctrl_key('n'))); + assert_eq!(app.outline_cursor(), 1); + assert!(app.outline_filter_focused()); + + update(&mut app, &km, &mut pending, AppEvent::Key(ctrl_key('p'))); + assert_eq!(app.outline_cursor(), 0); + assert!(app.outline_filter_focused()); + } + + #[test] + fn a_pending_confirm_wins_over_the_filter_input_capture() { + use workon_review::app::PendingOp; + + let mut app = filter_test_app(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + app.outline_filter_focus(); + app.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); + + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Char('y'))), + ); + + assert!( + app.pending_confirm.is_none(), + "the confirm modal must capture y first, per the documented Esc-precedence ladder" + ); + assert!( + app.outline_filter_focused(), + "the confirm arm must not have touched filter focus" + ); + } + + #[test] + fn a_mouse_event_is_swallowed_while_the_filter_input_has_capture() { + let mut app = filter_test_app(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + app.outline_filter_focus(); + let cursor_before = app.outline_cursor(); + + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Mouse(mouse(MouseEventKind::Down(MouseButton::Left))), + ); + + assert!(!quit); + assert!( + app.outline_filter_focused(), + "the click must not close the input" + ); + assert_eq!(app.outline_cursor(), cursor_before); + } } From 2462084f7d9305d8a36de5c6453d75496af9e06e Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 24 Jul 2026 12:59:00 -0400 Subject: [PATCH 185/203] fix(review): clear the outline filter on Esc before quitting --- git-workon-review/src/tui.rs | 82 +++++++++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 15 deletions(-) diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 8279cc8..b330c1a 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -689,12 +689,12 @@ enum KeyOutcome { Action(Action), } -/// Resolve one `Key` event to a [`KeyOutcome`], given the caller has already ruled out the two -/// modal cases (a pending discard confirm, the help overlay) — this is cases 3-6 of `update`'s -/// documented Esc-precedence cascade, extracted so [`update`] and [`update_batch`] share the exact -/// same resolution instead of duplicating it. +/// Resolve one `Key` event to a [`KeyOutcome`], given the caller has already ruled out the +/// modal cases (a pending discard confirm, the help overlay, the outline-filter input) — this is +/// cases 4-8 of `update`'s documented Esc-precedence cascade, extracted so [`update`] and +/// [`update_batch`] share the exact same resolution instead of duplicating it. /// -/// Clears any showing footer notice as a side effect, exactly like `update`'s cases 4-7 do (the +/// Clears any showing footer notice as a side effect, exactly like `update`'s cases 4-8 do (the /// confirm/help/filter-input modals deliberately do not — that stays in their own arms, not here). fn resolve_key( app: &mut App, @@ -707,6 +707,16 @@ fn resolve_key( app.cancel_selection(); return KeyOutcome::Handled; } + // CS2 (outline-filter): with the outline focused (the input row does NOT have capture — + // that's `update`'s case-3 modal arm) and a query actively narrowing the list, Esc unwinds + // the filter instead of quitting — mirroring how the selection-Esc arm above unwinds the + // diff's innermost mode before Esc's outer meanings apply. Only the NEXT Esc reaches the + // outline's terminal quit leaf. + if key.code == KeyCode::Esc && app.outline_focused() && !app.outline_filter_query().is_empty() { + app.clear_notice(); + app.outline_filter_clear(); + return KeyOutcome::Handled; + } app.clear_notice(); KeyOutcome::Action(map_key( keymap, @@ -765,10 +775,11 @@ fn apply_filter_input_key(app: &mut App, key: KeyEvent) { /// tick isn't the user acting on the message. /// /// Esc precedence (highest first): a pending discard confirm > the help overlay being open > the -/// CS2 outline-filter input having capture > an active line selection (diff-focused) > the -/// outline having focus > the diff having focus with the outline open > the normal key map (where -/// Esc quits). Concretely — the home-base model: the outline is where Esc always eventually lands -/// you before it quits. +/// CS2 outline-filter input having capture > an active line selection (diff-focused) > an active +/// outline-filter query (outline-focused) > the outline having focus > the diff having focus with +/// the outline open > the normal key map (where Esc quits). Concretely — the home-base model: the +/// outline is where Esc always eventually lands you before it quits, unwinding any inner mode +/// (selection, filter) along the way. /// /// 1. A pending discard confirm captures the keyboard FIRST (before the notice clear and the /// normal key map): `y` accepts, `n`/`Esc` cancels, and every other key is swallowed — a modal @@ -788,18 +799,22 @@ fn apply_filter_input_key(app: &mut App, key: KeyEvent) { /// other case, since none of them should observe a key the filter input itself consumes. /// 4. Otherwise, with an active line selection AND the diff focused, Esc CANCELS the selection /// instead of moving focus or quitting (`q` still quits). This arm is guarded to defer to case -/// 5 when the outline has focus (a selection can only be active while looking at the diff, but +/// 6 when the outline has focus (a selection can only be active while looking at the diff, but /// the guard keeps the precedence explicit). Other keys fall through to the normal map — /// `j`/`k` extend the selection, `s`/`d` act on it. -/// 5. Otherwise, while the outline pane has focus, Esc QUITS — same terminal leaf as `q`. The +/// 5. Otherwise, with the outline focused and a NON-EMPTY filter query (capture on the row list, +/// not the input — that's case 3), Esc CLEARS the filter ([`App::outline_filter_clear`]) +/// instead of quitting — the outline-side mirror of case 4's unwind-the-innermost-mode rule; +/// only the next Esc reaches case 6's quit leaf. +/// 6. Otherwise, while the outline pane has focus, Esc QUITS — same terminal leaf as `q`. The /// outline is home base; there's nowhere further out to walk to. -/// 6. Otherwise, with the diff focused and the outline OPEN, Esc walks outward one step: it +/// 7. Otherwise, with the diff focused and the outline OPEN, Esc walks outward one step: it /// focuses the outline (same effect as `h`/[`App::focus_outline`]) rather than quitting. -/// 7. Otherwise (diff focused, outline closed) the normal map applies, where Esc (like `q`) quits +/// 8. Otherwise (diff focused, outline closed) the normal map applies, where Esc (like `q`) quits /// — there's no outline to walk out to. /// -/// A `Key` event clears any showing footer notice before applying its own action (cases 4-7); the -/// confirm, help, and filter-input modals (cases 1-3) deliberately do not. Cases 4-7 are delegated +/// A `Key` event clears any showing footer notice before applying its own action (cases 4-8); the +/// confirm, help, and filter-input modals (cases 1-3) deliberately do not. Cases 4-8 are delegated /// to [`resolve_key`], shared with [`update_batch`]. fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: AppEvent) -> bool { match event { @@ -3731,6 +3746,43 @@ mod tests { assert_eq!(app.outline_filter_query(), "a"); } + #[test] + fn esc_on_the_list_clears_an_active_filter_before_the_next_esc_quits() { + let mut app = filter_test_app(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + app.outline_filter_focus(); + app.outline_filter_insert_char('a'); + app.outline_filter_unfocus(); + assert!(app.outline_focused(), "list (not input) must have capture"); + assert_eq!(app.outline_filter_query(), "a"); + + // First Esc: unwind the filter (ladder case 5) — clear the query, do NOT quit. + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); + assert!(!quit, "Esc with an active filter must not quit the review"); + assert!( + app.outline_filter_query().is_empty(), + "Esc on the list must clear the active filter query" + ); + + // Second Esc: the filter is gone, so the outline's terminal quit leaf (case 6) applies. + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); + assert!( + quit, + "with no filter left to unwind, Esc quits from the outline" + ); + } + #[test] fn ctrl_c_clears_the_query_and_returns_focus_to_the_list() { let mut app = filter_test_app(); From edc9106c1471b7c31b179cff5053cff602ba3caa Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 24 Jul 2026 13:13:54 -0400 Subject: [PATCH 186/203] fix(review): signal filter input capture with cursor and prefix color --- git-workon-review/src/render.rs | 85 ++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 7 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 91b42c0..6da9f60 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -1157,17 +1157,33 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) } /// CS2 (`outline-filter`): paint the one-row fuzzy-filter input at `area`'s first row — a leading -/// dim `/` prompt glyph (vim cmdline feel) followed by [`PromptState::render_line`]'s own -/// buffer+cursor spans. Theme-agnostic chrome around a theme-agnostic primitive (see -/// [`crate::prompt`]'s module doc on why `render_line` itself carries no palette dependency) — the -/// `/` glyph is the only themed element here, and it's just `theme.dim`, matching every other -/// quiet-chrome glyph in this module (tree guides, fold markers). +/// `/` prompt glyph (vim cmdline feel) followed by the query. The row renders in two visibly +/// distinct states, because capture (input vs row list) is otherwise indiscernible — the row is +/// present in both: +/// +/// - INPUT FOCUSED: the `/` glyph takes `theme.pane_header_focused_fg` (the same "your keys land +/// here" signal the pane headers use) and the buffer renders via [`PromptState::render_line`], +/// whose reversed cursor cell marks the edit point. +/// - LIST FOCUSED (query still applied): the `/` glyph is `theme.dim` — matching every other +/// quiet-chrome glyph in this module (tree guides, fold markers) — and the buffer renders as a +/// plain span with NO cursor cell: a block cursor only ever appears where keys actually land, +/// mirroring how the outline's own cursor row dims when the pane loses focus. fn render_outline_filter_input(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { + let focused = app.outline_filter_focused(); + let prefix_fg = if focused { + theme.pane_header_focused_fg + } else { + theme.dim + }; let mut spans = vec![TSpan::styled( "/".to_string(), - Style::default().fg(theme.dim), + Style::default().fg(prefix_fg), )]; - spans.extend(app.outline_filter_state().render_line().spans); + if focused { + spans.extend(app.outline_filter_state().render_line().spans); + } else { + spans.push(TSpan::raw(app.outline_filter_query().to_string())); + } let line = Line::from(spans); frame .buffer_mut() @@ -4653,6 +4669,61 @@ mod tests { ); } + #[test] + fn outline_filter_input_row_signals_capture_via_cursor_cell_and_prefix_color() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.outline_filter_focus(); + app.outline_filter_insert_char('b'); + let theme = Palette::dark(); + + let input_row_y = |buf: &Buffer| { + (0..buf.area.height) + .find(|&y| outline_row(buf, y).starts_with('/')) + .expect("an active filter must render its '/'-prefixed input row") + }; + + // INPUT focused: accent `/` prefix + a reversed cursor cell right after the query. + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let y = input_row_y(&buf); + assert_eq!( + buf.cell((0, y)).unwrap().style().fg, + Some(theme.pane_header_focused_fg), + "a focused input's '/' prefix must take the pane-header focus color" + ); + assert!( + buf.cell((2, y)) + .unwrap() + .style() + .add_modifier + .contains(Modifier::REVERSED), + "a focused input must show its block cursor (reversed cell after '/b')" + ); + + // LIST focused, query kept: dim `/` prefix, and NO reversed cell anywhere in the row — + // the block cursor only appears where keys actually land. + app.outline_filter_unfocus(); + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let y = input_row_y(&buf); + assert_eq!( + buf.cell((0, y)).unwrap().style().fg, + Some(theme.dim), + "an unfocused input's '/' prefix must drop back to quiet chrome" + ); + assert!( + (0..35).all(|x| !buf + .cell((x, y)) + .unwrap() + .style() + .add_modifier + .contains(Modifier::REVERSED)), + "no cursor cell may render while the row list has capture" + ); + } + #[test] fn outline_filter_query_narrows_the_rendered_rows() { let fixture = FixtureBuilder::new() From cae42e025c538ff7c6538dff842c1c531ecbf990 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 24 Jul 2026 14:32:39 -0400 Subject: [PATCH 187/203] fix(review): keep outline structure when filtering --- git-workon-review/src/app.rs | 158 +++--- git-workon-review/src/outline.rs | 880 ++++++++++++++++++++++++------- git-workon-review/src/render.rs | 3 +- 3 files changed, 788 insertions(+), 253 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 96d2fdb..afd0dcd 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -967,7 +967,8 @@ pub struct OutlineState { /// of the same path) for as long as the session runs. pub folds: HashMap>, /// CS2 (`outline-filter`, M11): the fuzzy-filter query, `/` while the outline has focus opens. - /// Read fresh every [`App::outline_items`] call (via [`outline::apply_filter`]) rather than + /// Read fresh every [`App::outline_items`] call (via [`outline::fold_outline_filtered`]) + /// rather than /// cached — persistence across a rebuild (staging op, mode cycle, refresh) is therefore free: /// the query itself just sits here untouched by any of those, so the very next /// [`App::outline_items`] call re-derives the same filtered view from the fresh row list. See @@ -2725,64 +2726,53 @@ impl App { }) } - /// [`Self::outline_folded`] with CS2's fuzzy filter layered on top when - /// [`OutlineState::filter`] holds a query — the outline cursor's SINGLE index space, and the - /// source of truth every other outline consumer reads: `render.rs`, + /// CS2's fuzzy filter, REVISED 2026-07-24: filter-then-rebuild — the outline cursor's SINGLE + /// index space, and the source of truth every other outline consumer reads: `render.rs`, /// [`Self::outline_move_by`]/[`Self::outline_move_to`], [`Self::outline_confirm`], /// [`Self::summary_target`], and the staging-verb resolution in [`Self::outline_row_targets`] /// all funnel through [`Self::outline_items`]/[`Self::outline_items_with_hidden_counts`] below /// — so an active filter can never silently retarget a cursor move or a stage/discard verb /// onto a row the filter itself hid. /// - /// An empty query returns [`Self::outline_folded`] UNCHANGED (zero regression when the filter - /// is unused — no [`outline::apply_filter`] call at all, so hidden-file markers and fold - /// structure render exactly as before this changeset). A non-empty query instead runs - /// [`outline::apply_filter`] over the fold-filtered rows and returns its flat, score-ordered - /// result with every hidden-file marker zeroed (a filtered row is never itself collapsed — - /// [`outline::apply_filter`] already reset its `guides`, and a chevron marker referring to - /// counts computed against the PRE-filter row list would be meaningless against this one) and - /// an identity `visible_index` (every surviving row maps onto its own position — nothing else - /// reads a filtered build's `visible_index`, since [`Self::outline_target_index`] special-cases - /// the filtered case instead of using it). + /// Delegates entirely to [`outline::fold_outline_filtered`], which scores every changeset's + /// title and every file's FULL path AT THE SOURCE, rebuilds the row list from the surviving + /// file set with the ordinary [`outline::build_items`]/[`outline::apply_fold`] machinery, and + /// only THEN folds — so headers, dir rows, tree guides, and hidden-count markers all come out + /// structurally correct instead of a flattened, re-ordered list. An empty query short-circuits + /// inside that fn to a plain [`outline::fold_outline`] call (the "zero regression when the + /// filter is unused" rule), so no special-casing is needed here. + fn outline_filtered_and_marks(&self) -> (outline::FoldedOutline, outline::FilterMarks) { + let snapshot = self.outline_snapshot(); + let folds = self.outline.folds.get(&self.outline.mode); + outline::fold_outline_filtered( + &snapshot, + self.outline.mode, + self.outline.order, + |key| folds.is_some_and(|set| set.contains(key)), + self.outline.filter.buffer(), + ) + } + + /// [`Self::outline_filtered_and_marks`]'s row list alone — see that method's doc comment for + /// the filter-then-rebuild composition, and [`Self::outline_items_with_hidden_counts`] for the + /// render-facing variant that also carries fold markers and match indices. fn outline_filtered(&self) -> outline::FoldedOutline { - let folded = self.outline_folded(); - if self.outline.filter.is_empty() { - return folded; - } - let filtered = outline::apply_filter(&folded.items, self.outline.filter.buffer()); - let hidden_counts = vec![0; filtered.items.len()]; - let visible_index = (0..filtered.items.len()).collect(); - outline::FoldedOutline { - items: filtered.items, - hidden_counts, - visible_index, - } + self.outline_filtered_and_marks().0 } - /// [`Self::outline_filtered`]'s row list — see that method's doc comment for the fold+filter - /// composition, and [`Self::outline_items_with_hidden_counts`] for the render-facing variant - /// that also carries fold markers and match indices. pub fn outline_items(&self) -> Vec { self.outline_filtered().items } /// [`Self::outline_items`], plus (aligned by index) each row's CS5 hidden-file marker count - /// and CS2's fuzzy-match char indices (empty when no filter is active, or for a row the query - /// didn't highlight any char of) — `render_outline`'s data source. Every OTHER outline - /// consumer uses [`Self::outline_items`] instead, which just discards what it doesn't need; - /// both funnel through the same [`Self::outline_filtered`]/[`Self::outline_folded`] build, so - /// they can never disagree about which rows are visible. + /// and CS2's fuzzy-match char indices (empty when no filter is active, or for a row that + /// isn't itself a match — see [`outline::FilterMarks`]'s doc comment) — `render_outline`'s + /// data source. pub fn outline_items_with_hidden_counts( &self, ) -> (Vec, Vec, Vec>) { - let folded = self.outline_folded(); - if self.outline.filter.is_empty() { - let match_indices = vec![Vec::new(); folded.items.len()]; - return (folded.items, folded.hidden_counts, match_indices); - } - let filtered = outline::apply_filter(&folded.items, self.outline.filter.buffer()); - let hidden_counts = vec![0; filtered.items.len()]; - (filtered.items, hidden_counts, filtered.match_indices) + let (folded, marks) = self.outline_filtered_and_marks(); + (folded.items, folded.hidden_counts, marks.match_indices) } /// Resolve a target row matched against the FULL (unfiltered, unfolded) row list to its @@ -2796,12 +2786,15 @@ impl App { /// fold-hidden target has no index in the fold-filtered list at all to match against. /// /// With a CS2 fuzzy filter active: `None` when the target row's own text didn't survive the - /// filter — a flat, re-ordered, re-scored filtered list has no ancestor-fallback story the way - /// a fold does (the locked design's "parents of matched children are NOT preserved" rule), so - /// there is genuinely no row to land `find`'s target on. Callers (currently only - /// [`Self::sync_outline_to_current`]) already treat `None` as "leave the cursor where it is, - /// clamped" — precisely the CS2 gotcha's "no-op instead of clearing the filter" requirement, - /// since neither branch here ever touches [`OutlineState::filter`] itself. + /// filter — REVISED 2026-07-24's rebuild DOES preserve ancestor Header/Dir rows, but `find` + /// here always matches a specific `File` row's true `cs_idx`/`file_idx` (see + /// [`Self::sync_outline_to_current`]'s call site), and a `File` row that didn't itself survive + /// filtering is genuinely absent from the rebuilt list — there's no "nearest surviving + /// ancestor" fallback for a FILTERED-out file the way a FOLDED-hidden one gets, since the + /// filter's ancestor rows carry no notion of "the file that would have been here." Callers + /// (currently only [`Self::sync_outline_to_current`]) already treat `None` as "leave the + /// cursor where it is, clamped" — precisely the CS2 gotcha's "no-op instead of clearing the + /// filter" requirement, since neither branch here ever touches [`OutlineState::filter`] itself. fn outline_target_index(&self, find: impl Fn(&OutlineItem) -> bool) -> Option { if !self.outline.filter.is_empty() { return self.outline_items().iter().position(find); @@ -3459,17 +3452,22 @@ impl App { self.sync_outline_to_current(); } - /// After any filter-input edit that can change the QUERY TEXT (so the matched/scored row set - /// itself just reshaped, not merely the cursor's position within a stable list): reseat the - /// outline cursor to the top-scored row — mirroring a picker reopening its list on every - /// keystroke — and re-derive the scroll. `render_body`'s summary-vs-diff branch - /// ([`Self::summary_target`]) and `outline_move_by`'s own switch-on-landing behavior both key - /// off `outline.cursor`, so parking it at `0` (the new best match) rather than leaving it at a - /// stale index is what makes typing feel live rather than leaving the cursor pointing at - /// whatever row happened to still be there. + /// After any filter-input edit that can change the QUERY TEXT (so the matched row set itself + /// just reshaped, not merely the cursor's position within a stable list): reseat the outline + /// cursor onto the HIGHEST-scoring row ([`outline::FilterMarks::best_index`], ties keeping the + /// earlier row) — mirroring a picker reopening its list on every keystroke — and re-derive the + /// scroll. REVISED 2026-07-24: the rebuilt row list is structural, not score-ordered, so "the + /// best match" is no longer always row `0` — it can be anywhere the rebuild placed it. Falls + /// back to `0` when no row carries a score at all (the query cleared back to empty, or the + /// rebuilt list is empty). `render_body`'s summary-vs-diff branch ([`Self::summary_target`]) + /// and `outline_move_by`'s own switch-on-landing behavior both key off `outline.cursor`, so + /// parking it at the new best match rather than leaving it at a stale index is what makes + /// typing feel live rather than leaving the cursor pointing at whatever row happened to still + /// be there. fn outline_filter_reflow(&mut self) { - self.outline.cursor = 0; - self.derive_outline_scroll(self.outline_items().len()); + let (folded, marks) = self.outline_filtered_and_marks(); + self.outline.cursor = marks.best_index().unwrap_or(0); + self.derive_outline_scroll(folded.items.len()); } /// Insert one typed char into the filter query (every non-control, non-Alt `Char` key while @@ -12677,9 +12675,21 @@ mod tests { app.outline_filter_insert_char('1'); let items = app.outline_items(); - assert_eq!(items.len(), 1, "only b1.txt fuzzy-matches 'b1'"); + // REVISED 2026-07-24: the rebuild keeps cs-b's Header alongside its surviving file — + // cs-a's Header (and both its files) is dropped entirely, since neither its title + // ("cs-a") nor a1.txt/a2.txt match "b1". assert_eq!( - items[0], + items.len(), + 2, + "cs-b's header rebuilds structurally alongside the one file that matches 'b1'" + ); + assert!( + matches!(items[0], OutlineItem::Header { cs_idx: 1, .. }), + "cs-b's header comes first (its own row), got {:?}", + items[0] + ); + assert_eq!( + items[1], OutlineItem::File { cs_idx: 1, file_idx: 0, @@ -12692,6 +12702,34 @@ mod tests { ); } + /// REVISED 2026-07-24: parking the cursor on "the best match" is no longer always row `0` — + /// the rebuilt list keeps cs-b's Header ahead of its own (only, and therefore best-scoring) + /// matching file, so the cursor must land on the FILE row, not the unscored header above it. + #[test] + fn outline_filter_reflow_parks_the_cursor_on_the_highest_scoring_row_not_row_zero() { + let mut app = two_committed_changesets_two_and_one_files(); + open_focused_outline(&mut app, OutlineMode::Stack, 0); + + app.outline_filter_insert_char('b'); + app.outline_filter_insert_char('1'); + + let items = app.outline_items(); + let file_idx = items + .iter() + .position(|it| matches!(it, OutlineItem::File { path, .. } if path == "b1.txt")) + .expect("b1.txt survives the 'b1' filter"); + assert_ne!( + file_idx, 0, + "sanity: the file row is NOT row 0 (the header is)" + ); + assert_eq!( + app.outline_cursor(), + file_idx, + "the cursor parks on b1.txt (the only scored row), not on cs-b's unscored header \ + at row 0" + ); + } + #[test] fn outline_items_empty_query_reproduces_the_unfiltered_fold_filtered_list() { let mut app = two_committed_changesets_two_and_one_files(); diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index d2b8446..811541b 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -270,12 +270,37 @@ pub(crate) fn build_items( changesets: &[OutlineChangeset], mode: OutlineMode, order: OutlineOrder, +) -> Vec { + build_items_inner(changesets, mode, order, None) +} + +/// [`build_items`] with CS2's per-row inclusion gate (`filter`) layered on — the REVISED +/// 2026-07-24 "rebuild from the surviving file set" entry point [`fold_outline_filtered`] calls. +/// Deliberately walks the SAME, full, unpruned `changesets` slice `build_items` does (see +/// [`is_included`]'s doc comment) — every `cs_idx`/`file_idx` this emits is therefore still +/// computed the exact same positional way `build_items` always has, so the true-index invariant +/// holds for free rather than needing a new index-carrying field on [`OutlineChangeset`]/ +/// [`OutlineFile`]. +fn build_items_filtered( + changesets: &[OutlineChangeset], + mode: OutlineMode, + order: OutlineOrder, + filter: &QueryMatches, +) -> Vec { + build_items_inner(changesets, mode, order, Some(filter)) +} + +fn build_items_inner( + changesets: &[OutlineChangeset], + mode: OutlineMode, + order: OutlineOrder, + filter: Option<&QueryMatches>, ) -> Vec { match mode { - OutlineMode::Flat => build_flat(changesets, order), - OutlineMode::Stack => build_stack(changesets, order), - OutlineMode::Tree => build_tree(changesets), - OutlineMode::StackTree => build_stack_tree(changesets, order), + OutlineMode::Flat => build_flat(changesets, order, filter), + OutlineMode::Stack => build_stack(changesets, order, filter), + OutlineMode::Tree => build_tree(changesets, filter), + OutlineMode::StackTree => build_stack_tree(changesets, order, filter), } } @@ -456,77 +481,239 @@ pub(crate) fn fold_outline( apply_fold(&items, is_folded) } -// ── Fuzzy filter (CS2 `outline-filter`) ───────────────────────────────────── - -/// `item`'s plain, undecorated text — what CS2's filter scores against and CS-render highlights -/// matched chars within. Deliberately the row's OWN field, never a reconstruction: a -/// [`OutlineItem::File`] row's `path` is already the full path in [`OutlineMode::Flat`]/ -/// [`OutlineMode::Stack`] but just the leaf segment in the tree modes (see [`OutlineItem::File`]'s -/// own doc comment) — the filter matches whatever that row is already carrying, not a -/// mode-independent "always full path" reconstruction, per the locked design's "matches against -/// the plain item text — never the decorated label" rule. -fn filter_text(item: &OutlineItem) -> &str { - match item { - OutlineItem::Header { label, .. } => label, - OutlineItem::Dir { name, .. } => name, - OutlineItem::File { path, .. } => path, - } -} +// ── Fuzzy filter (CS2 `outline-filter`, REVISED 2026-07-24: filter-then-rebuild) ──── -/// [`apply_filter`]'s output: the filtered, score-ordered row list, plus (parallel by index) each -/// surviving row's matched CHAR indices into [`filter_text`]'s own string — `render.rs`'s -/// highlight source. `guides` on every row is reset to empty (see [`apply_filter`]'s doc comment -/// on why a filtered result is always a flat list). +/// One row's fuzzy-match result against the SOURCE text it was scored on (a changeset's title, or +/// a file's FULL repo-relative path — never a dir segment or a tree leaf; REVISED 2026-07-24 drops +/// those as independent match targets). `score` is only meaningful compared against another +/// [`FilterMatch`] from the SAME query. `indices` are CHAR indices into that source text, not yet +/// remapped onto whatever text the eventual row displays — [`attach_filter_marks`] does that. +/// `matched_len` is that source text's own char count, which the tree-mode leaf remap needs to +/// compute the offset into a row that only displays the path's trailing segment. #[derive(Debug, Clone)] -pub(crate) struct FilteredOutline { - pub items: Vec, - pub match_indices: Vec>, +struct FilterMatch { + score: i64, + indices: Vec, + matched_len: usize, } -/// Score every row in `items` (a fold-filtered build's output) against `query` with -/// [`SkimMatcherV2`], drop non-matches, and order survivors by score descending — ties keep their -/// ORIGINAL relative order ([`Vec::sort_by`] is a stable sort, and the comparator only orders by -/// score, so two equal scores never swap). `cs_idx`/`file_idx` on every surviving [`OutlineItem`] -/// are untouched clones of `items`' own — CS2's "never re-index" invariant. +/// [`score_changesets`]'s output: every changeset/file's own [`FilterMatch`] (if it has one), +/// keyed by TRUE `cs_idx`/`file_idx` — never by array position, since the rebuild step below still +/// walks the FULL, unpruned `changesets` slice (see [`is_included`]'s doc comment for why nothing +/// here ever needs its own `cs_idx` field to stay correct). `matched_cs` is the "does this +/// changeset survive AT ALL" set (title match OR at least one file match) the stack-shaped +/// builders gate their header emission on. +struct QueryMatches { + header_matches: HashMap, + file_matches: HashMap<(usize, usize), FilterMatch>, + matched_cs: std::collections::HashSet, +} + +/// Score every changeset in `changesets` against `query` at the SOURCE, per REVISED 2026-07-24's +/// "match at the source, not the built rows" rule — in two tiers: +/// +/// 1. **Files first.** Score each file's FULL path individually; every match is recorded under +/// `file_matches` and the changeset enters `matched_cs`. +/// 2. **Titles only as a fallback**, when NO file anywhere in the snapshot matched. A title match +/// then keeps the WHOLE changeset (every file, unscored) via `header_matches`. /// -/// The locked design's "flat filtered list (parents of matched children are NOT preserved)" rule: -/// every surviving row's `guides` is reset to empty, regardless of the source [`OutlineMode`] — -/// a Tree/StackTree row's tree-guide vector describes connectors to ANCESTOR rows that a filtered, -/// re-ordered result no longer necessarily carries alongside it, so drawing them would show -/// dangling/wrong connectors. `render::build_outline_line` already reads an empty `guides` as "flat -/// indent, no tree connectors" for exactly this reason (see [`OutlineItem`]'s own doc comment), so -/// this reuses that existing fallback rather than adding a new render-side branch. +/// The fallback tier exists because titles are prose: a fuzzy subsequence like `"an"` matches +/// "Uncommitted ch·an·ges" (and half the titles in a real stack), so letting a title match +/// compete with file matches would routinely pull entire changesets into a query meant to narrow +/// to one file. Demoting titles to the no-file-results case keeps both intents predictable: +/// file-ish queries always narrow to files; a query that matches nothing BUT a title (typing a +/// changeset's name) still surfaces that changeset with all its files. /// -/// `query.is_empty()` is the caller's ("is a filter even active") gate, not this fn's — an empty -/// query here would fuzzy-match every row trivially (a no-op filter with meaningless scores), so -/// callers only invoke this once a query exists (see `App::outline_items`'s doc comment). -pub(crate) fn apply_filter(items: &[OutlineItem], query: &str) -> FilteredOutline { +/// A changeset with neither tier's match never enters `matched_cs`, which is what causes +/// [`build_stack`]/[`build_stack_tree`] to drop its header (and, transitively, +/// [`build_flat`]/[`build_tree`] to drop every one of its files) entirely. +fn score_changesets(changesets: &[OutlineChangeset], query: &str) -> QueryMatches { let matcher = SkimMatcherV2::default(); - let mut scored: Vec<(i64, OutlineItem, Vec)> = items - .iter() - .filter_map(|item| { - let (score, indices) = matcher.fuzzy_indices(filter_text(item), query)?; - let mut flat = item.clone(); - match &mut flat { - OutlineItem::Dir { guides, .. } | OutlineItem::File { guides, .. } => { - guides.clear(); + let mut out = QueryMatches { + header_matches: HashMap::new(), + file_matches: HashMap::new(), + matched_cs: std::collections::HashSet::new(), + }; + for (cs_idx, cs) in changesets.iter().enumerate() { + for (file_idx, file) in cs.files.iter().enumerate() { + if let Some((score, indices)) = matcher.fuzzy_indices(&file.path, query) { + out.file_matches.insert( + (cs_idx, file_idx), + FilterMatch { + score, + indices, + matched_len: file.path.chars().count(), + }, + ); + out.matched_cs.insert(cs_idx); + } + } + } + if out.file_matches.is_empty() { + for (cs_idx, cs) in changesets.iter().enumerate() { + if let Some((score, indices)) = matcher.fuzzy_indices(&cs.label, query) { + out.header_matches.insert( + cs_idx, + FilterMatch { + score, + indices, + matched_len: cs.label.chars().count(), + }, + ); + out.matched_cs.insert(cs_idx); + } + } + } + out +} + +/// Whether `(cs_idx, file_idx)` survives filtering: unconditionally `true` when `filter` is +/// `None` (the ordinary, unfiltered build every pre-CS2 test exercises), else `true` when either +/// the file's OWN path matched, or its changeset's TITLE matched (a title match "keeps the WHOLE +/// changeset, all files" — see [`score_changesets`]'s doc comment). +fn is_included(filter: Option<&QueryMatches>, cs_idx: usize, file_idx: usize) -> bool { + match filter { + None => true, + Some(f) => { + f.header_matches.contains_key(&cs_idx) + || f.file_matches.contains_key(&(cs_idx, file_idx)) + } + } +} + +/// [`fold_outline_filtered`]'s per-row output, parallel to its [`FoldedOutline::items`]: each +/// row's fuzzy-match char indices REMAPPED onto whatever text that row itself displays (empty if +/// the row isn't itself a match — an ancestor `Dir` kept only because a descendant survived, or a +/// `Header` kept only because a file survived), and each row's own score (`None` for the same +/// "not itself a match" rows) — [`Self::best_index`] is [`App::outline_filter_reflow`]'s +/// cursor-park source. +#[derive(Debug, Clone, Default)] +pub(crate) struct FilterMarks { + pub match_indices: Vec>, + pub scores: Vec>, +} + +impl FilterMarks { + fn empty_for(len: usize) -> Self { + FilterMarks { + match_indices: vec![Vec::new(); len], + scores: vec![None; len], + } + } + + /// The FIRST row (by rendered position) carrying the HIGHEST score, or `None` if no row in + /// this build has a score at all (no filter active, or — impossible in practice, since a + /// changeset only ever survives via its own or a file's match — every survivor is an + /// unscored ancestor). Ties keep the earlier row: the fold only replaces the running best on + /// a STRICTLY greater score. + pub(crate) fn best_index(&self) -> Option { + self.scores + .iter() + .enumerate() + .filter_map(|(i, s)| s.map(|score| (i, score))) + .fold(None, |best: Option<(usize, i64)>, (i, score)| match best { + Some((_, best_score)) if best_score >= score => best, + _ => Some((i, score)), + }) + .map(|(i, _)| i) + } +} + +/// [`fold_outline_filtered`]'s final step: remap [`score_changesets`]'s SOURCE-text match indices +/// onto whatever text each rebuilt+folded row actually displays, and carry each row's own score +/// alongside. +/// +/// - [`OutlineItem::Header`]: the row's own `label` IS the text that was scored (a title match), +/// so its indices need no remap. +/// - [`OutlineItem::File`]: `file_matches` indices address the FULL path that was scored. In +/// [`OutlineMode::Flat`]/[`OutlineMode::Stack`] (empty `guides`) the row's own `path` field IS +/// that full path — no remap. In [`OutlineMode::Tree`]/[`OutlineMode::StackTree`] (non-empty +/// `guides`) the row displays only the LEAF segment (the ancestor [`OutlineItem::Dir`] rows +/// already carry the rest — see [`OutlineItem::File`]'s own doc comment). A trie leaf is always +/// the full path's own TRAILING segment, so shifting every index left by `matched_len - +/// leaf_len` lands it in the leaf's own char range; an index that shifts negative addressed a +/// character in an ancestor directory segment this row doesn't render, so it's dropped. +/// - [`OutlineItem::Dir`]: deliberately left UNHIGHLIGHTED. REVISED 2026-07-24 drops dir rows as +/// independent match targets, and a surviving dir can have several children whose match spans +/// disagree — picking one arbitrarily (or unioning spans from unrelated files) would misrepresent +/// what actually matched. Leaving dir rows plain is the simple, honest choice; render still +/// dims them via the existing tree-guide styling, so they read as quiet structure either way. +fn attach_filter_marks( + items: &[OutlineItem], + header_matches: &HashMap, + file_matches: &HashMap<(usize, usize), FilterMatch>, +) -> FilterMarks { + let mut marks = FilterMarks::empty_for(items.len()); + for (i, item) in items.iter().enumerate() { + match item { + OutlineItem::Header { cs_idx, .. } => { + if let Some(m) = header_matches.get(cs_idx) { + marks.match_indices[i] = m.indices.clone(); + marks.scores[i] = Some(m.score); } - OutlineItem::Header { .. } => {} } - Some((score, flat, indices)) - }) - .collect(); - scored.sort_by(|a, b| b.0.cmp(&a.0)); + OutlineItem::File { + cs_idx, + file_idx, + path, + guides, + .. + } => { + if let Some(m) = file_matches.get(&(*cs_idx, *file_idx)) { + marks.scores[i] = Some(m.score); + marks.match_indices[i] = if guides.is_empty() { + m.indices.clone() + } else { + let leaf_len = path.chars().count(); + let offset = m.matched_len.saturating_sub(leaf_len); + m.indices + .iter() + .filter_map(|&idx| idx.checked_sub(offset)) + .filter(|&shifted| shifted < leaf_len) + .collect() + }; + } + } + OutlineItem::Dir { .. } => {} + } + } + marks +} - let mut result = FilteredOutline { - items: Vec::with_capacity(scored.len()), - match_indices: Vec::with_capacity(scored.len()), - }; - for (_, item, indices) in scored { - result.items.push(item); - result.match_indices.push(indices); +/// [`score_changesets`] + rebuild ([`build_items_filtered`]) + [`apply_fold`] + +/// [`attach_filter_marks`] composed — `App::outline_filtered`'s single entry point (mirrors how +/// [`fold_outline`] composes the fold-only case). `query.is_empty()` short-circuits straight to a +/// plain [`fold_outline`] call with every mark empty/`None` — the "zero regression when the +/// filter is unused" rule, now enforced HERE rather than duplicated by every caller. +/// +/// REVISED 2026-07-24's "rebuild, don't post-filter" rule: the surviving changesets/files are fed +/// back through the SAME [`build_items`]/[`apply_fold`] machinery every other build uses (via +/// [`build_items_filtered`]'s inclusion gate), so headers, dir rows, tree guides, fold behavior, +/// and hidden-count markers all come out structurally correct — no flattening, no guide reset. +/// Ordering is therefore the outline's ordinary structural order, never score-descending; the +/// score is used ONLY to pick [`App::outline_filter_reflow`]'s cursor-park target via +/// [`FilterMarks::best_index`]. +pub(crate) fn fold_outline_filtered( + changesets: &[OutlineChangeset], + mode: OutlineMode, + order: OutlineOrder, + is_folded: impl Fn(&FoldKey) -> bool, + query: &str, +) -> (FoldedOutline, FilterMarks) { + if query.is_empty() { + let folded = fold_outline(changesets, mode, order, is_folded); + let marks = FilterMarks::empty_for(folded.items.len()); + return (folded, marks); } - result + let matches = score_changesets(changesets, query); + let items = build_items_filtered(changesets, mode, order, &matches); + let folded = apply_fold(&items, is_folded); + let marks = attach_filter_marks( + &folded.items, + &matches.header_matches, + &matches.file_matches, + ); + (folded, marks) } /// [`OutlineMode::Stack`]: a header per changeset, then its files in order — no de-duplication, @@ -534,10 +721,23 @@ pub(crate) fn apply_filter(items: &[OutlineItem], query: &str) -> FilteredOutlin /// own row under its own header. `order` picks which end of the stack paints first; `cs_idx`/ /// `file_idx` are computed from the ORIGINAL (base -> head) enumeration before any reversal, so /// they stay true indices into `App::changesets` either way. -fn build_stack(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec { +/// +/// `filter` (REVISED 2026-07-24): `None` builds every row, exactly as before this changeset — +/// `Some` skips a changeset's header (and every one of its files) entirely when it isn't in +/// `matched_cs`, and skips an individual surviving changeset's own non-matching files via +/// [`is_included`]. Never renumbers: `cs_idx`/`file_idx` are still read straight off the SAME +/// positional scan, so a filtered build's indices are exactly as true as an unfiltered one's. +fn build_stack( + changesets: &[OutlineChangeset], + order: OutlineOrder, + filter: Option<&QueryMatches>, +) -> Vec { let n = changesets.len(); let mut items = Vec::new(); for (cs_idx, cs) in scan_order(changesets, order) { + if filter.is_some_and(|f| !f.matched_cs.contains(&cs_idx)) { + continue; + } items.push(OutlineItem::Header { cs_idx, n, @@ -548,6 +748,9 @@ fn build_stack(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec Vec head regardless of which way /// the row list is displayed, so the resolution below reuses it rather than re-deriving from the /// (possibly reversed) `order` scan used for display order. -fn build_flat(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec { +/// +/// `filter` (REVISED 2026-07-24): the de-dupe target resolution (which occurrence a shared path +/// jumps to) is untouched by filtering — it's derived from the FULL, unpruned `changesets`, same +/// as always. Only the FINAL emit step changes: a path is dropped when its resolved (closest-to- +/// head) occurrence itself doesn't survive [`is_included`] — even if some OLDER, non-displayed +/// occurrence of the same path would have matched, since Flat mode never shows that older copy +/// anyway (unfiltered or not). +fn build_flat( + changesets: &[OutlineChangeset], + order: OutlineOrder, + filter: Option<&QueryMatches>, +) -> Vec { let latest = latest_by_path(changesets); let mut order_list: Vec = Vec::new(); let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); @@ -583,16 +797,19 @@ fn build_flat(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec Vec { +/// +/// `filter` (REVISED 2026-07-24): same de-dupe-then-gate story as [`build_flat`] — an excluded +/// occurrence's path is simply never inserted into the trie, so its ancestor `Dir` rows vanish +/// too whenever it was their only surviving child (a dir with zero inserted descendants never +/// gets emitted at all — see [`emit`]). +fn build_tree(changesets: &[OutlineChangeset], filter: Option<&QueryMatches>) -> Vec { let latest = latest_by_path(changesets); let mut root = TrieNode::default(); for (path, occ) in &latest { + if !is_included(filter, occ.cs_idx, occ.file_idx) { + continue; + } let segments: Vec<&str> = path.split('/').collect(); root.insert(&segments, *occ); } @@ -749,10 +974,20 @@ fn build_tree(changesets: &[OutlineChangeset]) -> Vec { /// each changeset trie is built from just that changeset's files, matching `build_stack`'s "every /// changeset's own copy gets its own row" rule). `order` picks which end of the stack paints /// first, same as [`build_stack`]; `cs_idx`/`file_idx` stay true indices regardless. -fn build_stack_tree(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec { +/// +/// `filter` (REVISED 2026-07-24): same header-skip gate as [`build_stack`], plus the same +/// never-insert-an-excluded-file gate [`build_tree`] uses for its own per-changeset trie. +fn build_stack_tree( + changesets: &[OutlineChangeset], + order: OutlineOrder, + filter: Option<&QueryMatches>, +) -> Vec { let n = changesets.len(); let mut items = Vec::new(); for (cs_idx, cs) in scan_order(changesets, order) { + if filter.is_some_and(|f| !f.matched_cs.contains(&cs_idx)) { + continue; + } items.push(OutlineItem::Header { cs_idx, n, @@ -764,6 +999,9 @@ fn build_stack_tree(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec }); let mut root = TrieNode::default(); for (file_idx, file) in cs.files.iter().enumerate() { + if !is_included(filter, cs_idx, file_idx) { + continue; + } let segments: Vec<&str> = file.path.split('/').collect(); root.insert( &segments, @@ -1562,10 +1800,21 @@ mod tests { assert_eq!(folded.hidden_counts, vec![2]); } - // ── Fuzzy filter (CS2 `outline-filter`) ───────────────────────────────────── + // ── Fuzzy filter (CS2 `outline-filter`, REVISED 2026-07-24: filter-then-rebuild) ──── + + /// `fold_outline_filtered` with an always-visible fold (no key folded) — the shape most of + /// these tests want; a couple below pass their own predicate to check fold interaction. + fn filtered( + changesets: &[OutlineChangeset], + mode: OutlineMode, + order: OutlineOrder, + query: &str, + ) -> (FoldedOutline, FilterMarks) { + fold_outline_filtered(changesets, mode, order, |_| false, query) + } #[test] - fn apply_filter_drops_non_matches_and_keeps_true_indices_on_survivors() { + fn fold_outline_filtered_drops_non_matches_and_keeps_true_indices_on_survivors() { let changesets = vec![cs( "cs-a", true, @@ -1575,157 +1824,404 @@ mod tests { ("README.md", StagedStatus::None), ], )]; - let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst); - let filtered = apply_filter(&items, "app"); - assert_eq!( - filtered.items.len(), - 1, - "only src/app.rs fuzzy-matches 'app'; the header and README.md don't" + let (folded, _) = filtered( + &changesets, + OutlineMode::Stack, + OutlineOrder::BaseFirst, + "app", ); assert_eq!( - filtered.items[0], - OutlineItem::File { - cs_idx: 0, - file_idx: 0, - path: "src/app.rs".to_string(), - status: StagedStatus::None, - change: FileStatus::Modified, - guides: Vec::new(), - }, - "the surviving row keeps its TRUE cs_idx/file_idx into App::changesets" + folded.items, + vec![ + OutlineItem::Header { + cs_idx: 0, + n: 1, + label: "cs-a".to_string(), + current: true, + needs_restack: false, + loading: false, + failed: false, + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 0, + path: "src/app.rs".to_string(), + status: StagedStatus::None, + change: FileStatus::Modified, + guides: Vec::new(), + }, + ], + "the header rebuilds structurally (unlike the pre-REVISED flat list), and \ + src/app.rs keeps its TRUE cs_idx/file_idx (0, 0) — README.md (file_idx 1) is \ + dropped, it never matches 'app'" ); } #[test] - fn apply_filter_orders_survivors_by_score_descending_stable_on_ties() { - // Two files whose paths are byte-identical apart from a prefix that doesn't affect the - // query's match at all — SkimMatcherV2 scores an exact substring match identically - // regardless of an unrelated prefix, so these two rows tie, and the stable sort must keep - // them in their ORIGINAL (a-before-b) relative order. - let items = vec![ - OutlineItem::File { - cs_idx: 0, - file_idx: 0, - path: "widget.rs".to_string(), - status: StagedStatus::None, - change: FileStatus::Modified, - guides: Vec::new(), - }, - OutlineItem::File { - cs_idx: 0, - file_idx: 1, - path: "widget.rs".to_string(), - status: StagedStatus::None, - change: FileStatus::Modified, - guides: Vec::new(), - }, + fn fold_outline_filtered_preserves_structural_order_not_score_order() { + // Same two strings/query the pre-REVISED `apply_filter` score-ordering test used to prove + // "src/x/app_helper.rs" (a long, scattered match) scores LOWER than "app.rs" (an exact, + // unbroken substring match) — but cs-a (the lower-scoring file's changeset) renders + // FIRST here, because REVISED 2026-07-24 drops the old score-descending sort entirely in + // favor of the outline's ordinary base -> head structural order. + let changesets = vec![ + cs( + "cs-a", + false, + false, + &[("src/x/app_helper.rs", StagedStatus::None)], + ), + cs("cs-b", true, false, &[("app.rs", StagedStatus::None)]), ]; - let filtered = apply_filter(&items, "widget"); - assert_eq!(filtered.items.len(), 2); + let (folded, marks) = filtered( + &changesets, + OutlineMode::Stack, + OutlineOrder::BaseFirst, + "app.rs", + ); + let paths: Vec<&str> = folded + .items + .iter() + .filter_map(|it| match it { + OutlineItem::File { path, .. } => Some(path.as_str()), + _ => None, + }) + .collect(); assert_eq!( - filtered.items, items, - "equal-scoring rows keep their original relative order" + paths, + vec!["src/x/app_helper.rs", "app.rs"], + "cs-a's (lower-scoring) file still renders before cs-b's (higher-scoring) one — \ + base -> head structural order, not score order" ); + let scattered_idx = folded + .items + .iter() + .position( + |it| matches!(it, OutlineItem::File { path, .. } if path == "src/x/app_helper.rs"), + ) + .unwrap(); + let exact_idx = folded + .items + .iter() + .position(|it| matches!(it, OutlineItem::File { path, .. } if path == "app.rs")) + .unwrap(); + assert!( + marks.scores[exact_idx].unwrap() > marks.scores[scattered_idx].unwrap(), + "app.rs's exact match must still score higher than the scattered one, even though \ + it renders SECOND" + ); + } - // A query that scores "app.rs" higher than "src/x/app_helper.rs" (an exact, unbroken - // substring match outranks a scattered one) must sort the exact match first even though - // it appears LATER in the input. - let mixed = vec![ - OutlineItem::File { - cs_idx: 0, - file_idx: 0, - path: "src/x/app_helper.rs".to_string(), - status: StagedStatus::None, - change: FileStatus::Modified, - guides: Vec::new(), - }, - OutlineItem::File { - cs_idx: 0, - file_idx: 1, - path: "app.rs".to_string(), - status: StagedStatus::None, - change: FileStatus::Modified, - guides: Vec::new(), - }, - ]; - let filtered = apply_filter(&mixed, "app.rs"); + #[test] + fn fold_outline_filtered_title_match_keeps_the_whole_changesets_files_unscored() { + let changesets = vec![cs( + "release-widget", + true, + false, + &[ + ("one.rs", StagedStatus::None), + ("two.rs", StagedStatus::None), + ], + )]; + let (folded, marks) = filtered( + &changesets, + OutlineMode::Stack, + OutlineOrder::BaseFirst, + "release", + ); + assert_eq!( + folded.items, + vec![ + OutlineItem::Header { + cs_idx: 0, + n: 1, + label: "release-widget".to_string(), + current: true, + needs_restack: false, + loading: false, + failed: false, + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 0, + path: "one.rs".to_string(), + status: StagedStatus::None, + change: FileStatus::Modified, + guides: Vec::new(), + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 1, + path: "two.rs".to_string(), + status: StagedStatus::None, + change: FileStatus::Modified, + guides: Vec::new(), + }, + ], + "a title match on 'release-widget' keeps EVERY file, even though neither one.rs nor \ + two.rs matches 'release' on its own" + ); + assert!( + marks.scores[0].is_some(), + "the header itself is the match, so it carries a score" + ); assert_eq!( - filtered.items[0], mixed[1], - "the exact substring match ('app.rs') must outrank the scattered one, despite \ - appearing second in the input" + marks.scores[1..].to_vec(), + vec![None, None], + "the files were never individually scored — kept only because their changeset's \ + title matched" ); } #[test] - fn apply_filter_scores_the_per_mode_plain_text_not_the_decorated_label() { - let changesets = vec![deep_path_changeset("release-widget", true, false)]; - // Header: matches the changeset's label/title. - let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst); - let filtered = apply_filter(&items, "release"); - assert!( - filtered.items.iter().any( - |it| matches!(it, OutlineItem::Header { label, .. } if label == "release-widget") + fn a_file_match_anywhere_suppresses_the_title_fallback_tier() { + // "an" fuzzy-matches the prose title "refactor changes" (subsequence: ch·an·ges) AND the + // file banana.txt. Titles are a FALLBACK tier only: because a file matched somewhere, the + // title-matched changeset must NOT survive — otherwise short file queries would pull in + // whole changesets through their prose titles (the regression: "an" vs the ever-present + // "Uncommitted changes" label). + let changesets = vec![ + cs( + "refactor changes", + false, + false, + &[("zzz.qqq", StagedStatus::None)], ), - "a Header row must match against its label" + cs("other", true, false, &[("banana.txt", StagedStatus::None)]), + ]; + let (folded, _) = filtered( + &changesets, + OutlineMode::Stack, + OutlineOrder::BaseFirst, + "an", ); - - // Dir: matches the bare segment name, not the full path. - let tree_items = build_items(&changesets, OutlineMode::Tree, OutlineOrder::HeadFirst); - let filtered = apply_filter(&tree_items, "src"); assert!( - filtered + folded .items .iter() - .any(|it| matches!(it, OutlineItem::Dir { name, .. } if name == "src")), - "a Dir row must match against its own segment name" + .all(|it| !matches!(it, OutlineItem::Header { cs_idx: 0, .. })), + "the title-matched changeset must be suppressed by the file match, got {:?}", + folded.items ); + assert!( + folded.items.iter().any( + |it| matches!(it, OutlineItem::File { cs_idx: 1, path, .. } if path == "banana.txt") + ), + "banana.txt (the file-tier match) survives with its true indices, got {:?}", + folded.items + ); + } - // File: matches the row's own `path` field (leaf-only in tree modes). - let filtered = apply_filter(&tree_items, "top.rs"); + #[test] + fn fold_outline_filtered_keeps_dir_ancestors_for_a_deep_tree_match() { + let changesets = vec![deep_path_changeset("cs-a", true, false)]; + // 'b.rs' matches only src/a/b.rs (none of c.rs/d.rs/top.rs contain a 'b'). + let (folded, marks) = filtered( + &changesets, + OutlineMode::Tree, + OutlineOrder::HeadFirst, + "b.rs", + ); + assert_eq!( + folded.items, + vec![ + OutlineItem::Dir { + name: "src".to_string(), + path: "src".to_string(), + cs_idx: None, + // Every guide is `true` here (unlike the UNFILTERED build's [false]/ + // [false,false]/[false,false,true]): once c.rs/d.rs/top.rs are filtered out, + // src/ and src/a/ each become their PARENT's only (and therefore last) + // child, and b.rs becomes src/a/'s only child too. + guides: vec![true], + }, + OutlineItem::Dir { + name: "a".to_string(), + path: "src/a".to_string(), + cs_idx: None, + guides: vec![true, true], + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 1, + path: "b.rs".to_string(), + status: StagedStatus::None, + change: FileStatus::Modified, + guides: vec![true, true, true], + }, + ], + "a deep match rebuilds its ancestor src/ and src/a/ Dir rows, with CORRECT (re-derived,\ + not stale) tree guides — no other row (c.rs, d.rs, top.rs) survives" + ); assert!( - filtered - .items - .iter() - .any(|it| matches!(it, OutlineItem::File { path, .. } if path == "top.rs")), - "a File row must match against its own path field" + marks.match_indices[0].is_empty() && marks.match_indices[1].is_empty(), + "the ancestor Dir rows are left unhighlighted (REVISED 2026-07-24: dir rows are no \ + longer independent match targets, and picking one child's span to show on a \ + multi-child dir would be misleading — see attach_filter_marks's doc comment)" + ); + assert_eq!( + marks.match_indices[2], + vec![0, 1, 2, 3], + "'b.rs' matches the leaf's own full text; since the leaf IS the whole matched \ + suffix here, the remap is a no-op shift of 0" ); } #[test] - fn apply_filter_resets_guides_to_a_flat_list_even_for_tree_mode_survivors() { + fn fold_outline_filtered_remaps_a_tree_leafs_match_indices_off_the_ancestor_prefix() { + // 'a/b' scores against the FULL path "src/a/b.rs", matching the 'a', '/', 'b' run that + // straddles the src/a/ ancestor prefix and the b.rs leaf's own first char — only the + // leaf-local portion should survive the remap onto the rendered leaf text "b.rs". let changesets = vec![deep_path_changeset("cs-a", true, false)]; - let items = build_items(&changesets, OutlineMode::Tree, OutlineOrder::HeadFirst); - let filtered = apply_filter(&items, "b.rs"); - assert_eq!(filtered.items.len(), 1); - match &filtered.items[0] { - OutlineItem::File { guides, path, .. } => { - assert_eq!(path, "b.rs"); - assert!( - guides.is_empty(), - "a filtered result renders flat — no dangling tree connectors to \ - now-absent ancestor rows" - ); - } - other => panic!("expected a File row, got {other:?}"), - } + let (folded, marks) = filtered( + &changesets, + OutlineMode::Tree, + OutlineOrder::HeadFirst, + "a/b", + ); + let leaf_idx = folded + .items + .iter() + .position(|it| matches!(it, OutlineItem::File { path, .. } if path == "b.rs")) + .expect("b.rs survives the 'a/b' query"); + // "src/a/b.rs": indices of 'a' (4), '/' (5), 'b' (6) — the leaf "b.rs" starts at char 6 + // (full length 10, leaf length 4, offset 6). Only the 'b' at index 6 shifts into the + // leaf's own [0, 4) range (shifted to 0); 'a' and the preceding '/' shift negative and + // are dropped. + assert_eq!( + marks.match_indices[leaf_idx], + vec![0], + "only the leaf-local 'b' survives the remap; the ancestor-segment 'a' and '/' \ + matches are dropped, not misrendered onto the wrong chars of 'b.rs'" + ); } #[test] - fn apply_filter_match_indices_are_parallel_to_the_surviving_items() { - let items = vec![OutlineItem::File { - cs_idx: 0, - file_idx: 0, - path: "app.rs".to_string(), - status: StagedStatus::None, - change: FileStatus::Modified, - guides: Vec::new(), - }]; - let filtered = apply_filter(&items, "app"); - assert_eq!(filtered.items.len(), filtered.match_indices.len()); + fn fold_outline_filtered_stack_tree_mode_keeps_dir_ancestors_too() { + let changesets = vec![deep_path_changeset("cs-a", true, false)]; + let (folded, _) = filtered( + &changesets, + OutlineMode::StackTree, + OutlineOrder::HeadFirst, + "b.rs", + ); + assert_eq!( + folded.items, + vec![ + OutlineItem::Header { + cs_idx: 0, + n: 1, + label: "cs-a".to_string(), + current: true, + needs_restack: false, + loading: false, + failed: false, + }, + OutlineItem::Dir { + name: "src".to_string(), + path: "src".to_string(), + cs_idx: Some(0), + guides: vec![true], + }, + OutlineItem::Dir { + name: "a".to_string(), + path: "src/a".to_string(), + cs_idx: Some(0), + guides: vec![true, true], + }, + OutlineItem::File { + cs_idx: 0, + file_idx: 1, + path: "b.rs".to_string(), + status: StagedStatus::None, + change: FileStatus::Modified, + guides: vec![true, true, true], + }, + ], + "StackTree mode also rebuilds a deep match's ancestor Dir rows under its header, \ + with correct guides" + ); + } + + #[test] + fn fold_outline_filtered_empty_query_is_a_zero_regression_no_op() { + let changesets = vec![cs( + "cs-a", + true, + false, + &[ + ("a1.txt", StagedStatus::None), + ("a2.txt", StagedStatus::None), + ], + )]; + let plain = fold_outline( + &changesets, + OutlineMode::Stack, + OutlineOrder::BaseFirst, + |_| false, + ); + let (folded, marks) = + filtered(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst, ""); + assert_eq!( + folded.items, plain.items, + "an empty query must reproduce the plain fold_outline build exactly" + ); + assert_eq!(folded.hidden_counts, plain.hidden_counts); + assert_eq!(folded.visible_index, plain.visible_index); + assert!( + marks.scores.iter().all(Option::is_none) + && marks.match_indices.iter().all(Vec::is_empty), + "no filter active means no row carries a score or a highlight" + ); + } + + #[test] + fn fold_outline_filtered_folds_the_rebuilt_tree_same_as_an_unfiltered_build() { + // The fold applies to the REBUILT (post-filter) row list, not the pre-filter one — a + // collapsed src/ should still hide its filtered-in descendant. + let changesets = vec![deep_path_changeset("cs-a", true, false)]; + let (folded, _) = fold_outline_filtered( + &changesets, + OutlineMode::Tree, + OutlineOrder::HeadFirst, + |key| { + *key == FoldKey::Dir { + path: "src".to_string(), + owner: None, + } + }, + "b.rs", + ); + assert_eq!( + folded.items, + vec![OutlineItem::Dir { + name: "src".to_string(), + path: "src".to_string(), + cs_idx: None, + // `true`, not `false`: with c.rs/d.rs/top.rs filtered out, src/ is root's only + // (and therefore last) surviving child — see the sibling test above. + guides: vec![true], + }], + "src/ survives collapsed, but its own (filtered-in) b.rs descendant stays hidden" + ); + assert_eq!( + folded.hidden_counts, + vec![1], + "src/'s marker counts its one hidden (but filter-surviving) file" + ); + } + + #[test] + fn filter_marks_best_index_picks_the_first_strictly_highest_score() { + let marks = FilterMarks { + match_indices: vec![Vec::new(); 4], + scores: vec![Some(1), Some(5), Some(5), None], + }; assert_eq!( - filtered.match_indices[0], - vec![0, 1, 2], - "'app' matches the first three chars of 'app.rs' contiguously" + marks.best_index(), + Some(1), + "the first of the two tied-highest scores (index 1) wins, not the later one" ); + assert_eq!(FilterMarks::empty_for(3).best_index(), None); } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 6da9f60..6ea4385 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -199,7 +199,8 @@ fn changeset_title_spans( /// CS2 (`outline-filter`): render `text` char-by-char, layering [`Modifier::UNDERLINED`] on top of /// `base_style` for every char whose index is in `match_indices` (CHAR indices into `text`, from -/// [`fuzzy_matcher::skim::SkimMatcherV2::fuzzy_indices`] via [`crate::outline::apply_filter`]) — +/// [`fuzzy_matcher::skim::SkimMatcherV2::fuzzy_indices`], remapped onto this row's own displayed +/// text by [`crate::outline::fold_outline_filtered`]'s internals) — /// reuses the row's own EXISTING foreground/dim color rather than introducing a new theme field: /// M11's later diff-search slice is what adds dedicated `tint_slot` match-highlight keys (per the /// plan), so this filter — which CS2 owns start to finish — stays theme-neutral. Groups From f006ba140aefce30adceadaea24cbb171d333813 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Thu, 23 Jul 2026 18:53:04 -0400 Subject: [PATCH 188/203] feat(review): add search within the current diff file --- git-workon-review/src/align.rs | 40 +++ git-workon-review/src/app.rs | 531 +++++++++++++++++++++++++++++++- git-workon-review/src/config.rs | 35 +++ git-workon-review/src/keymap.rs | 48 ++- git-workon-review/src/lib.rs | 1 + git-workon-review/src/render.rs | 137 +++++++- git-workon-review/src/search.rs | 356 +++++++++++++++++++++ git-workon-review/src/theme.rs | 55 +++- git-workon-review/src/tui.rs | 230 +++++++++++--- 9 files changed, 1371 insertions(+), 62 deletions(-) create mode 100644 git-workon-review/src/search.rs diff --git a/git-workon-review/src/align.rs b/git-workon-review/src/align.rs index f57f625..71db9de 100644 --- a/git-workon-review/src/align.rs +++ b/git-workon-review/src/align.rs @@ -386,6 +386,46 @@ pub(crate) fn gap_hidden_range( Some((run_start + effective_before, run_end - effective_after)) } +/// The gap `key` (the hidden run's start index, matching [`DisplayRow::Gap`]'s own `key`) whose +/// UNEXPANDED context run contains `aligned_idx`, or `None` when `aligned_idx` isn't inside a +/// context run at all, or that run is too short to ever collapse (same `keep_before`/`keep_after`/ +/// `run_len` test [`collapse_gaps_inner`] uses — a run collapse decision never depends on the +/// current [`GapExpansion`] state, only on the run's own length and position). M11 CS3 (search): +/// a match address lives in the pre-collapse `AlignedRow` space, so jumping to one that isn't +/// currently visible needs this reverse lookup — "which gap, if any, would need expanding to +/// reveal this row" — before [`crate::app::FileView::expand_gap`] can be called with the right key. +pub(crate) fn gap_key_for_aligned_idx(rows: &[AlignedRow], aligned_idx: usize) -> Option { + let is_context = |row: &AlignedRow| { + matches!( + (row.old_kind, row.new_kind), + (CellKind::Context, CellKind::Context) + ) + }; + if aligned_idx >= rows.len() || !is_context(&rows[aligned_idx]) { + return None; + } + let mut run_start = aligned_idx; + while run_start > 0 && is_context(&rows[run_start - 1]) { + run_start -= 1; + } + let mut run_end = aligned_idx + 1; + while run_end < rows.len() && is_context(&rows[run_end]) { + run_end += 1; + } + let run_len = run_end - run_start; + + let keep_before = if run_start == 0 { 0 } else { CONTEXT_LINES }; + let keep_after = if run_end == rows.len() { + 0 + } else { + CONTEXT_LINES + }; + if (keep_before == 0 && keep_after == 0) || run_len <= keep_before + keep_after { + return None; + } + Some(run_start) +} + /// One row of the inline (unified, single-column) display. /// /// Built by [`inline_rows`] from the SAME gap-collapsed [`DisplayRow`] vector [`collapse_gaps`] diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index afd0dcd..cbbb517 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -462,6 +462,18 @@ impl FileView { .cloned() .unwrap_or_default() } + + /// M11 CS3 (search): literal, smartcase matches of `query` against this file's PRE-collapse + /// row space — see [`crate::search::compute_matches`]'s doc comment for why that space (not + /// [`Self::display`]/[`Self::inline`]) is what's scanned. + pub(crate) fn search_matches(&self, query: &str) -> Vec { + crate::search::compute_matches( + &self.aligned, + query, + |n| self.old_line(n).to_string(), + |n| self.new_line(n).to_string(), + ) + } } /// The tree a COMBINED-role [`FileView`]'s old side reads from (see [`FileView::load`]'s role @@ -1446,6 +1458,29 @@ pub struct App { /// [`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, + /// M11 CS3 (`diff-search`): the ACCEPTED search query, `/` in the diff view opens the prompt + /// to edit. Survives file/changeset switches (vim-register semantics — see + /// [`Self::recompute_search`]'s doc comment for what recomputes it on which trigger). `None` + /// while no search has ever been + /// accepted, or after [`Self::search_clear`]. + search_query: Option, + /// The one-row prompt's own live editing buffer — separate from [`Self::search_query`] so + /// typing previews highlights without committing them: [`Self::search_accept`] (`Enter`) is + /// the only path that copies this into `search_query`; [`Self::search_abort`] (`Esc`) discards + /// it, leaving `search_query` (and its highlights) exactly as they were before `/` was pressed. + search_prompt: PromptState, + /// Whether the search prompt currently has keyboard capture — the diff-view analog of + /// [`OutlineState::filter_focused`]'s two-state model, except search has no "focus the prompt, + /// keep typing history" step: every `/` starts a fresh, empty prompt (see [`Self::search_focus`]). + search_focused: bool, + /// The CURRENT search text's matches (the live prompt buffer's while [`Self::search_focused`], + /// else [`Self::search_query`]'s) against the focused pane's file, in file order — recomputed + /// by [`Self::recompute_search`] on every trigger the M11 CS3 plan names: prompt edits, accept, + /// abort, file/changeset switch, refresh, layout/zoom change. + search_matches: Vec, + /// Index into [`Self::search_matches`] of the match the cursor is currently parked on — + /// `None` while merely previewing (typing, before `Enter`) or when there's nothing to park on. + search_current: Option, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -1631,6 +1666,11 @@ impl App { pending_wave: None, zoom_key_label: "Z".to_string(), config_reload_requested: false, + search_query: None, + search_prompt: PromptState::new(), + search_focused: false, + search_matches: Vec::new(), + search_current: None, }; // 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 @@ -2193,6 +2233,10 @@ impl App { } self.derive_scroll(); // The unfocused pane's scroll is derived at render time, once its height is known. + // M11 CS3: `reset_panes` is the one chokepoint every file/changeset switch, refresh, and + // zoom cycle already funnels through (`open_current`/`complete_pending_open` both end + // here) — see [`Self::recompute_search`]'s doc comment for the full trigger list. + self.recompute_search(); } /// Jump the focused pane's cursor to its role view's first hunk, then re-derive `scroll`. @@ -3523,6 +3567,296 @@ impl App { self.outline_filter_reflow(); } + // ── Diff search (CS3 `diff-search`, M11) ───────────────────────────────────── + + /// Whether the search prompt currently has keyboard capture (`/` opened it, `Enter`/`Esc` + /// haven't closed it yet) — `tui.rs`'s modal-capture cascade arm and mouse-swallow guard, and + /// `render.rs`'s footer prompt, all gate on this. + pub fn search_focused(&self) -> bool { + self.search_focused + } + + /// Whether an ACCEPTED search is live (survives the prompt closing) — the fallback gate for + /// `n`/`N` (contextual hunk-nav fallback when this is `false`) and the Esc-precedence ladder's + /// "clear the active search" arm. + pub fn search_active(&self) -> bool { + self.search_query.is_some() + } + + /// The prompt's own live editing buffer, for `render.rs`'s footer prompt (mirrors + /// [`Self::outline_filter_state`]). + pub fn search_prompt_state(&self) -> &PromptState { + &self.search_prompt + } + + /// The current search's matches (recomputed by [`Self::recompute_search`]), in file order. + pub fn search_matches(&self) -> &[crate::search::SearchMatch] { + &self.search_matches + } + + /// Index into [`Self::search_matches`] the cursor is currently parked on, if any — the + /// distinguishing mark between `search-match-bg` (every match) and `search-current-bg` (this + /// one) `render.rs` paints. + pub fn search_current_index(&self) -> Option { + self.search_current + } + + /// `/` in the diff view: open a FRESH, empty search prompt (unlike the outline filter, the + /// search prompt never pre-fills from the last accepted query — vim's `/` doesn't either). + pub fn search_focus(&mut self) { + self.search_prompt.clear(); + self.search_focused = true; + self.recompute_search(); + } + + /// The text driving [`Self::search_matches`] right now: the live prompt buffer while + /// [`Self::search_focused`] (so typing previews highlights), else the last ACCEPTED query — + /// this is what makes [`Self::search_abort`] "restore the previously accepted search" free + /// (closing the prompt without touching `search_query` just switches which text + /// [`Self::recompute_search`] reads next). + fn active_search_text(&self) -> Option<&str> { + if self.search_focused && !self.search_prompt.is_empty() { + Some(self.search_prompt.buffer()) + } else { + self.search_query.as_deref() + } + } + + /// Recompute [`Self::search_matches`] from [`Self::active_search_text`] against the FOCUSED + /// pane's current file view — called on every trigger the M11 CS3 plan names: every prompt + /// edit (live preview), accept/abort, file/changeset switch and refresh (both funnel through + /// [`Self::reset_panes`]), and a layout/zoom change (harmless to re-run even when the match + /// content can't have changed — matches address the layout-agnostic `AlignedRow` space). + /// [`Self::search_current`] always resets to `None` here: a fresh match list has no "the + /// cursor is parked on match N" claim to make until [`Self::search_accept`]/ + /// [`Self::search_next`]/[`Self::search_prev`] jumps to one. + fn recompute_search(&mut self) { + self.search_current = None; + let Some(text) = self.active_search_text() else { + self.search_matches.clear(); + return; + }; + if text.is_empty() { + self.search_matches.clear(); + return; + } + let text = text.to_string(); + self.search_matches = match self.current_view_ref() { + Some(view) => view.search_matches(&text), + None => Vec::new(), + }; + } + + /// `Enter` while the prompt is focused: commit the buffer as the accepted query, close the + /// prompt, and jump to the first match at-or-after the cursor (wrapping to the file's first + /// match, with a footer notice, if none is at-or-after it). An empty buffer clears the search + /// instead of accepting a no-op query. + pub fn search_accept(&mut self) { + let text = self.search_prompt.buffer().to_string(); + self.search_focused = false; + self.search_prompt.clear(); + if text.is_empty() { + self.search_clear(); + return; + } + self.search_query = Some(text); + self.recompute_search(); + if self.search_matches.is_empty() { + return; + } + match self.first_match_at_or_after_cursor() { + Some(idx) => self.jump_to_search_match(idx, false), + None => self.jump_to_search_match(0, true), + } + } + + /// `Esc` while the prompt is focused: discard the buffer, close the prompt, and restore + /// whichever accepted search (or none) was active before `/` was pressed — free, since + /// [`Self::active_search_text`] already falls back to [`Self::search_query`] once + /// [`Self::search_focused`] is `false`. + pub fn search_abort(&mut self) { + self.search_focused = false; + self.search_prompt.clear(); + self.recompute_search(); + } + + /// `Esc` with the diff focused and an active search (no prompt open) — clears the search + /// entirely, the Esc-precedence ladder's own arm (ranked with the line-selection cancel, see + /// `tui.rs::resolve_key`). + pub fn search_clear(&mut self) { + self.search_query = None; + self.search_focused = false; + self.search_prompt.clear(); + self.search_matches.clear(); + self.search_current = None; + } + + /// Insert a char at the prompt's cursor and recompute the live preview. + pub fn search_insert_char(&mut self, c: char) { + self.search_prompt.insert_char(c); + self.recompute_search(); + } + + /// `Backspace` while the prompt is focused. + pub fn search_backspace(&mut self) { + self.search_prompt.backspace(); + self.recompute_search(); + } + + /// `Delete` while the prompt is focused. + pub fn search_delete(&mut self) { + self.search_prompt.delete(); + self.recompute_search(); + } + + /// `Left` while the prompt is focused — doesn't reshape the match list, so no recompute. + pub fn search_move_left(&mut self) { + self.search_prompt.move_left(); + } + + /// `Right` while the prompt is focused — see [`Self::search_move_left`]. + pub fn search_move_right(&mut self) { + self.search_prompt.move_right(); + } + + /// `Ctrl-a`/`Home` while the prompt is focused. + pub fn search_move_home(&mut self) { + self.search_prompt.move_home(); + } + + /// `Ctrl-e`/`End` while the prompt is focused. + pub fn search_move_end(&mut self) { + self.search_prompt.move_end(); + } + + /// `Ctrl-u` while the prompt is focused. + pub fn search_clear_to_start(&mut self) { + self.search_prompt.clear_to_start(); + self.recompute_search(); + } + + /// `Ctrl-w` while the prompt is focused. + pub fn search_delete_word_back(&mut self) { + self.search_prompt.delete_word_back(); + self.recompute_search(); + } + + /// The cursor's own (old, new) lineno pair, preferring the new-side lineno like + /// [`Self::restore_position`]'s convention — the ordering key [`Self::first_match_at_or_after_cursor`] + /// compares [`crate::search::SearchMatch`]'s own (old, new) pair against. + fn cursor_lineno(&self) -> Option { + let view = self.current_view_ref()?; + let (old, new) = match self.layout { + Layout::Sbs => display_row_linenos(view.display.get(self.cursor)?), + Layout::Inline => inline_row_linenos(view.inline.get(self.cursor)?), + }; + new.or(old) + } + + /// The first [`Self::search_matches`] index whose row is at-or-after the cursor's own + /// position, by (new-preferring) lineno — [`Self::search_accept`]'s jump target. `None` when + /// every match lies strictly before the cursor (the wrap case its caller handles). + fn first_match_at_or_after_cursor(&self) -> Option { + let cursor_line = self.cursor_lineno().unwrap_or(0); + self.search_matches.iter().position(|m| { + let line = m.new_lineno.or(m.old_lineno).unwrap_or(0); + line >= cursor_line + }) + } + + /// `n`/`N` (contextual): jump to the next/previous search match when a search is active, + /// wrapping (with a footer notice) at either end; falls back to [`Self::next_hunk_row`]/ + /// [`Self::prev_hunk_row`] when no search is active at all (`search-next`/`search-prev`'s + /// registry description names this fallback). + fn search_step(&mut self, forward: bool) { + if !self.search_active() || self.search_matches.is_empty() { + if forward { + self.next_hunk_row(); + } else { + self.prev_hunk_row(); + } + return; + } + let n = self.search_matches.len(); + let (next_idx, wrapped) = match self.search_current { + Some(cur) => { + if forward { + let next = (cur + 1) % n; + (next, next < cur) + } else { + let next = (cur + n - 1) % n; + (next, next > cur) + } + } + // No prior current (a search just accepted, or `n`/`N` pressed before any jump): + // land on the nearest match at-or-after the cursor either direction — same starting + // point [`Self::search_accept`] itself would have picked. + None => match self.first_match_at_or_after_cursor() { + Some(idx) => (idx, false), + None => (0, true), + }, + }; + self.jump_to_search_match(next_idx, wrapped); + } + + /// `n` (default binding `search-next`): see [`Self::search_step`]. + pub fn search_next(&mut self) { + self.search_step(true); + } + + /// `N` (default binding `search-prev`): see [`Self::search_step`]. + pub fn search_prev(&mut self) { + self.search_step(false); + } + + /// Park the cursor on [`Self::search_matches`]`[idx]`: auto-expand the gap it's hidden behind + /// (if any — [`crate::align::gap_key_for_aligned_idx`] + [`FileView::expand_gap`], the + /// existing CS8/CS9 machinery), then locate the row in the ACTIVE layout's own vector by the + /// match's (old, new) lineno pair and land there. `wrapped` raises the footer notice the plan + /// calls for; a match whose row can't be located post-expansion (should be unreachable once + /// expanded) leaves the cursor where it was rather than panicking. + fn jump_to_search_match(&mut self, idx: usize, wrapped: bool) { + let Some(&m) = self.search_matches.get(idx) else { + return; + }; + self.search_current = Some(idx); + + if let Some(view) = self.current_view() { + if let Some(key) = crate::align::gap_key_for_aligned_idx(&view.aligned, m.aligned_idx) { + let hidden = crate::align::gap_hidden_range(&view.aligned, key, &view.expansions) + .is_some_and(|(start, end)| m.aligned_idx >= start && m.aligned_idx < end); + if hidden { + view.expand_gap(key, 0, 0, true); + } + } + } + + let layout = self.layout; + if let Some(view) = self.current_view_ref() { + let target = (m.old_lineno, m.new_lineno); + let row = match layout { + Layout::Sbs => view + .display + .iter() + .position(|r| display_row_linenos(r) == target), + Layout::Inline => view + .inline + .iter() + .position(|r| inline_row_linenos(r) == target), + }; + if let Some(row) = row { + self.cursor = row; + } + } + + self.cancel_selection(); + self.derive_scroll(); + self.clamp_cursor(); + if wrapped { + self.notify("search wrapped", Severity::Info); + } + } + // ── Outline staging (CS7) ─────────────────────────────────────────────────── /// Whether the changeset at `cs_idx` is a committed range rather than the uncommitted @@ -4206,6 +4540,10 @@ impl App { }; } self.derive_scroll(); + // M11 CS3: named as a recompute trigger by the plan even though the match ADDRESSES + // (aligned-space) can't actually change here — only which display/inline row each one + // resolves to. Cheap to re-run regardless (see [`Self::recompute_search`]'s doc comment). + self.recompute_search(); } /// Set the render layout directly — the config-startup (CS7) counterpart to @@ -4363,6 +4701,7 @@ impl App { }; } self.derive_scroll(); + self.recompute_search(); } if self.outline.mode != outline_mode_before || self.outline.order != outline_order_before { @@ -5330,8 +5669,10 @@ fn row_lineno(row: Row) -> Option { } /// The (old, new) 1-based line numbers a display row occupies — `None` on a filler side, and -/// `(None, None)` for a gap row (which belongs to no hunk). -fn display_row_linenos(row: &DisplayRow) -> (Option, Option) { +/// `(None, None)` for a gap row (which belongs to no hunk). `pub(crate)`: `render.rs`'s M11 CS3 +/// search-highlight lookup reuses this exact pairing (the same key [`crate::search::SearchMatch`] +/// carries) rather than re-deriving its own. +pub(crate) fn display_row_linenos(row: &DisplayRow) -> (Option, Option) { match row { DisplayRow::Row(r) => (row_lineno(r.old), row_lineno(r.new)), DisplayRow::Gap { .. } => (None, None), @@ -5375,8 +5716,8 @@ fn gap_scope_start( Some((scope_start, anchor_prefers_new)) } -/// Inline-coordinate analog of [`display_row_linenos`]. -fn inline_row_linenos(row: &InlineRow) -> (Option, Option) { +/// Inline-coordinate analog of [`display_row_linenos`]. `pub(crate)` for the same reason. +pub(crate) fn inline_row_linenos(row: &InlineRow) -> (Option, Option) { match *row { InlineRow::Context { old, new } => (Some(old), Some(new)), InlineRow::Del { old, .. } => (Some(old), None), @@ -12130,6 +12471,188 @@ mod tests { ); } + // ── M11 CS3 (`diff-search`) ────────────────────────────────────────────── + + /// [`two_hunks_with_a_wide_gap_fixture`], but the middle of the hidden context run carries a + /// unique needle (`ctx20` → `needle_line`) — CS3's "hidden-context rows are searchable, and + /// jumping to one auto-expands its gap" fixture. + fn two_hunks_with_a_buried_needle_fixture() -> Fixture { + let mut committed = String::from("OLD_HUNK_A\n"); + let mut modified = String::from("NEW_HUNK_A\n"); + for i in 1..=40 { + let line = if i == 20 { + "needle_line".to_string() + } else { + format!("ctx{i}") + }; + committed.push_str(&line); + committed.push('\n'); + modified.push_str(&line); + modified.push('\n'); + } + committed.push_str("OLD_HUNK_B\n"); + modified.push_str("NEW_HUNK_B\n"); + + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", &committed, &modified) + .build() + .unwrap() + } + + #[test] + fn search_finds_a_match_hidden_inside_a_collapsed_gap() { + let fixture = two_hunks_with_a_buried_needle_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + app.search_focus(); + for c in "needle".chars() { + app.search_insert_char(c); + } + assert_eq!( + app.search_matches().len(), + 1, + "the buried needle must be found even while its gap is still collapsed" + ); + } + + #[test] + fn search_accept_jumps_to_the_first_match_and_auto_expands_its_gap() { + let fixture = two_hunks_with_a_buried_needle_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert!( + app.current_view_ref() + .unwrap() + .display + .iter() + .any(|r| matches!(r, DisplayRow::Gap { .. })), + "precondition: the fixture's wide context run must start out collapsed" + ); + + app.search_focus(); + for c in "needle".chars() { + app.search_insert_char(c); + } + app.search_accept(); + + let view = app.current_view_ref().unwrap(); + assert!( + !view + .display + .iter() + .any(|r| matches!(r, DisplayRow::Gap { .. })), + "jumping to a match buried in the gap must fully reveal it: {:?}", + view.display + ); + match view.display[app.cursor] { + DisplayRow::Row(row) => { + assert_eq!(row.old, Row::Line(21), "needle_line is old-side line 21"); + } + other => panic!("expected the cursor to land on the needle's row, got {other:?}"), + } + assert!(!app.search_focused(), "accept must close the prompt"); + assert!(app.search_active()); + } + + #[test] + fn search_next_and_prev_wrap_with_a_footer_notice() { + // Two occurrences of the SAME needle on two different visible lines (both hunk change + // rows, so no gap machinery is in play here — this test is purely about n/N wrap). + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "f.txt", + "alpha old\nctx\nbeta old\n", + "alpha needle\nctx\nbeta needle\n", + ) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + app.search_focus(); + for c in "needle".chars() { + app.search_insert_char(c); + } + app.search_accept(); + assert_eq!(app.search_matches().len(), 2); + assert_eq!(app.search_current_index(), Some(0)); + + app.search_next(); + assert_eq!(app.search_current_index(), Some(1)); + assert!( + app.notice.is_none(), + "advancing without wrapping raises no notice" + ); + + app.search_next(); + assert_eq!( + app.search_current_index(), + Some(0), + "n at the last match wraps to the first" + ); + assert!( + app.notice.is_some(), + "wrapping forward must raise a footer notice" + ); + + app.clear_notice(); + app.search_prev(); + assert_eq!( + app.search_current_index(), + Some(1), + "N at the first match wraps to the last" + ); + assert!( + app.notice.is_some(), + "wrapping backward must raise a footer notice too" + ); + } + + #[test] + fn search_next_and_prev_fall_back_to_hunk_nav_with_no_active_search() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert!( + !app.search_active(), + "precondition: no search has ever been accepted" + ); + + let cursor_before = app.cursor; + app.search_next(); + assert_eq!( + app.cursor, + find_next_hunk_row(&app.current_view_ref().unwrap().display, cursor_before) + .unwrap_or(cursor_before), + "with no active search, search-next must fall back to next-hunk" + ); + } + + #[test] + fn esc_with_diff_focused_and_an_active_search_clears_it_before_walking_out_to_the_outline() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.outline.open = true; + + app.search_focus(); + app.search_insert_char('c'); // "ctx..." lines all match a bare 'c' + app.search_accept(); + assert!(app.search_active()); + + // The keymap-driven Esc ladder itself lives in `tui.rs`; this pins the `App`-level state + // transition `App::search_clear` provides for that ladder's arm. + app.search_clear(); + assert!( + !app.search_active(), + "Esc's search-clear arm must deactivate the search" + ); + assert!(app.search_matches().is_empty()); + } + // ── 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 9fd738c..66dd7cd 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -260,6 +260,8 @@ fn tint_slot<'a>(overrides: &'a mut ThemeOverrides, key: &str) -> Option<&'a mut "cursor-unfocused-bg" => &mut overrides.cursor_unfocused_bg, "pane-header-focused-fg" => &mut overrides.pane_header_focused_fg, "filler-fg" => &mut overrides.filler_fg, + "search-match-bg" => &mut overrides.search_match_bg, + "search-current-bg" => &mut overrides.search_current_bg, _ => return None, }) } @@ -908,6 +910,39 @@ mod tests { assert_eq!(palette.cursor_unfocused_bg, Color::Rgb(0x1a, 0x2b, 0x3c)); } + #[test] + fn theme_overrides_reads_the_search_match_bg_tint_key() { + // M11 CS3 (`diff-search`): `search-match-bg`/`search-current-bg` are brand-new tints with + // no scheme slot fallback — this is the ONLY way to set them (see `tint_slot`'s doc + // comment), and they must NOT be in `KNOWN_SCALAR_KEYS` (the open-ended `theme.*` + // subspace already covers them for the unknown-key warning). + use crate::theme::Palette; + + let fixture = FixtureBuilder::new() + .config("workon.review.theme.search-match-bg", "#1a2b3c") + .config("workon.review.theme.search-current-bg", "#4d5e6f") + .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.search_match_bg, + Some(Color::Rgb(0x1a, 0x2b, 0x3c)) + ); + assert_eq!( + overrides.search_current_bg, + Some(Color::Rgb(0x4d, 0x5e, 0x6f)) + ); + + let mut palette = Palette::dark(); + palette.apply_overrides(&overrides); + assert_eq!(palette.search_match_bg, Color::Rgb(0x1a, 0x2b, 0x3c)); + assert_eq!(palette.search_current_bg, Color::Rgb(0x4d, 0x5e, 0x6f)); + } + #[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, diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index dda786b..46c28e0 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -72,6 +72,9 @@ pub enum Command { ExpandAllGaps, HscrollLeft, HscrollRight, + Search, + SearchNext, + SearchPrev, // Diff view. FocusOutline, // Outline view. @@ -271,7 +274,10 @@ pub static REGISTRY: &[Registered] = &[ command: Command::NextHunk, view: View::Diff, name: "next-hunk", - default_keys: "]h n", + // `n` moved off this default (M11 CS3, `diff-search`): it's now `search-next`'s default, + // which itself falls back to this exact action when no search is active — see that row's + // description. `]h` alone still reaches it directly. + default_keys: "]h", description: "Go to the next hunk", }, Registered { @@ -344,6 +350,27 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "zR", description: "Reveal every collapsed gap in the file", }, + Registered { + command: Command::Search, + view: View::Diff, + name: "search", + default_keys: "/", + description: "Search within the current file", + }, + Registered { + command: Command::SearchNext, + view: View::Diff, + name: "search-next", + default_keys: "n", + description: "Next search match (or next hunk, when no search is active)", + }, + Registered { + command: Command::SearchPrev, + view: View::Diff, + name: "search-prev", + default_keys: "N", + description: "Previous search match (or previous hunk, when no search is active)", + }, // ── Outline view ───────────────────────────────────────────────────────── Registered { command: Command::OutlineDown, @@ -1264,23 +1291,26 @@ 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 + /// CS3 (diff-fold-keys) originally bound `n` as an extra default on `next-hunk`, for symmetry + /// with the outline's `n`/`p` changeset nav. M11 CS3 (`diff-search`) reclaims `n` as + /// `search-next`'s default instead (falling back to `next-hunk` itself when no search is + /// active — `App::search_next` — so `n`'s PRACTICAL effect on an unbound-search diff is + /// unchanged); `p` is untouched, still `prev-hunk`'s extra default. `primary_key` still picks + /// the first token, so the footer/help keep showing `]h`/`[h` for `next-hunk`/`prev-hunk` + /// themselves — neither is 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() { + fn n_dispatches_search_next_and_p_still_dispatches_prev_hunk_with_no_collisions() { let km = Keymap::defaults(); assert!( km.warnings().is_empty(), - "n/p hunk-nav defaults must not collide with anything: {:?}", + "n/p defaults must not collide with anything: {:?}", km.warnings() ); assert_eq!( feed(&km, false, &[key(KeyCode::Char('n'))]), - Dispatch::Command(Command::NextHunk) + Dispatch::Command(Command::SearchNext) ); assert_eq!( feed(&km, false, &[key(KeyCode::Char('p'))]), diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 44ade65..efe3cab 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -32,6 +32,7 @@ pub mod queue; pub mod refresh; pub mod render; pub mod scope; +pub mod search; pub mod source; pub mod stage_op; pub mod summary; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 6ea4385..7f1c32d 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -25,6 +25,7 @@ use crate::icons::IconMode; use crate::keymap::{footer_hint, help_sections, Keymap}; use crate::model::FileStatus; use crate::outline::OutlineItem; +use crate::search::SearchSide; use crate::summary::{ChangesetSummary, DirSummary, SummaryFileRow}; use crate::theme::Palette; use crate::wordiff::Span as WordSpan; @@ -689,6 +690,7 @@ fn content_spans( theme: &Palette, hscroll: usize, text_mode: DiffTextMode, + search_spans: &[(usize, usize, Color)], ) -> Vec> { let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); let mut fg_override_spans: Vec<(usize, usize, Color)> = Vec::new(); @@ -729,6 +731,12 @@ fn content_spans( } } + // M11 CS3: pushed LAST so search highlighting wins the `compose_segments` reverse-scan lookup + // over del/add/word-diff emphasis on the same bytes — the plan's "composite with existing row + // washes the way other bg tints do" (see `compose_segments`'s doc comment on push-order + // precedence). + bg_spans.extend_from_slice(search_spans); + 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() { @@ -750,6 +758,53 @@ fn content_spans( pan_spans(spans, hscroll, theme) } +/// Which pane [`search_bg_spans`] is resolving highlights for — [`Side`]'s own name is already +/// taken by the SBS old/new distinction this mirrors; kept separate since the inline layout has +/// no [`Side`] of its own (a `Del`/`Add`/`Context` row IS one side already). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SearchRenderSide { + Old, + New, +} + +/// M11 CS3 (`diff-search`): the `search-match-bg`/`search-current-bg` background spans to paint +/// for a row occupying `(old_lineno, new_lineno)`, on `render_side`. Matches whose +/// [`SearchSide::Both`] (a context row) paint on EITHER render side; `Old`/`New` matches paint +/// only their own. A linear scan of every active match per row/side call — cheap at review-sized +/// files/match counts, and simpler than a per-frame lookup table to get right without being able +/// to compile-test it here (see the changeset's HARD CONSTRAINTS on running cargo). +fn search_bg_spans( + app: &App, + old_lineno: Option, + new_lineno: Option, + render_side: SearchRenderSide, + theme: &Palette, +) -> Vec<(usize, usize, Color)> { + if old_lineno.is_none() && new_lineno.is_none() { + return Vec::new(); + } + let current = app.search_current_index(); + app.search_matches() + .iter() + .enumerate() + .filter(|(_, m)| m.old_lineno == old_lineno && m.new_lineno == new_lineno) + .filter(|(_, m)| match (m.side, render_side) { + (SearchSide::Both, _) => true, + (SearchSide::Old, SearchRenderSide::Old) => true, + (SearchSide::New, SearchRenderSide::New) => true, + _ => false, + }) + .map(|(i, m)| { + let color = if Some(i) == current { + theme.search_current_bg + } else { + theme.search_match_bg + }; + (m.start, m.end, color) + }) + .collect() +} + /// Build a single rendered line for one pane at a display row's resolved [`Row`]/[`CellKind`]. #[allow(clippy::too_many_arguments)] fn build_pane_line( @@ -765,6 +820,7 @@ fn build_pane_line( theme: &Palette, hscroll: usize, text_mode: DiffTextMode, + search_spans: &[(usize, usize, Color)], ) -> Line<'static> { match row { Row::Filler => { @@ -813,6 +869,7 @@ fn build_pane_line( theme, hscroll, text_mode, + search_spans, )); Line::from(spans) } @@ -1652,9 +1709,10 @@ fn diff_header_line(app: &App, theme: &Palette, icons: IconMode, focused: bool) Line::from(spans) } -/// Footer priority: a pending discard confirm's prompt (warn-toned) wins over a transient notice, -/// which wins over the curated hint line (CS3) — a notice TEMPORARILY REPLACES the hint rather -/// than adding a second row; it clears on the user's next keypress (`tui::update`). +/// Footer priority: a pending discard confirm's prompt (warn-toned) wins over the M11 CS3 search +/// prompt (while it has capture), which wins over a transient notice, which wins over the curated +/// hint line (CS3) — a notice TEMPORARILY REPLACES the hint rather than adding a second row; it +/// clears on the user's next keypress (`tui::update`). fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, theme: &Palette) { if let Some(confirm) = &app.pending_confirm { frame.render_widget( @@ -1663,6 +1721,38 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, them ); return; } + if app.search_focused() { + render_search_prompt(frame, app, area, theme); + return; + } + render_footer_notice_or_hint(frame, app, area, keymap, theme); +} + +/// M11 CS3 (`diff-search`): paint the one-row search prompt in the footer while it has capture — +/// the same leading dim glyph + [`PromptState::render_line`] shape as the outline's fuzzy-filter +/// input ([`render_outline_filter_input`]), just relocated to the footer (a vim-cmdline feel, +/// per the plan) instead of a carve-out at the top of a pane. +fn render_search_prompt(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { + let mut spans = vec![TSpan::styled( + "/".to_string(), + Style::default().fg(theme.dim), + )]; + spans.extend(app.search_prompt_state().render_line().spans); + let line = Line::from(spans); + frame + .buffer_mut() + .set_line(area.x, area.y, &line, area.width); +} + +/// The notice-or-hint half of [`render_footer`] — split out so the confirm/search-prompt priority +/// tiers above stay a flat early-return chain rather than nesting this whole match inside them. +fn render_footer_notice_or_hint( + frame: &mut Frame, + app: &App, + area: Rect, + keymap: &Keymap, + theme: &Palette, +) { match &app.notice { Some(Notice { text, severity }) => { let fg = match severity { @@ -2346,6 +2436,21 @@ fn render_pane_sbs( (Vec::new(), Vec::new()) }; + // M11 CS3: this row's (old, new) lineno pair — the same key `SearchMatch` carries + // — resolved once and reused for both sides' highlight lookups below. + let old_lineno = match row.old { + Row::Line(n) => Some(n), + Row::Filler => None, + }; + let new_lineno = match row.new { + Row::Line(n) => Some(n), + Row::Filler => None, + }; + let old_search = + search_bg_spans(app, old_lineno, new_lineno, SearchRenderSide::Old, theme); + let new_search = + search_bg_spans(app, old_lineno, new_lineno, SearchRenderSide::New, theme); + let old_line = build_pane_line( view, Side::Old, @@ -2359,6 +2464,7 @@ fn render_pane_sbs( theme, hscroll, text_mode, + &old_search, ); let new_line = build_pane_line( view, @@ -2373,6 +2479,7 @@ fn render_pane_sbs( theme, hscroll, text_mode, + &new_search, ); // Cursor wins over selection on the same row (see [`Palette::selection_bg`]). let (old_line, new_line) = if is_cursor { @@ -2446,6 +2553,7 @@ fn build_inline_line( theme: &Palette, hscroll: usize, text_mode: DiffTextMode, + search_spans: &[(usize, usize, Color)], ) -> Line<'static> { let (old_opt, new_opt, text, hl, kind) = match *row { InlineRow::Context { old, new } => ( @@ -2512,6 +2620,7 @@ fn build_inline_line( theme, hscroll, text_mode, + search_spans, )); Line::from(spans) } @@ -2592,6 +2701,20 @@ fn render_pane_inline( InlineRow::Add { .. } => &new_spans, _ => &[], }; + // M11 CS3: this row's (old, new) lineno pair, and which side's text is actually + // rendered here (a `Context` row renders `view.new_line` — see + // `build_inline_line`'s own match — so it queries the New side; content is + // identical to Old for a context row, and `compute_matches` only ever tags a + // context match `SearchSide::Both`, which matches either render side). + let (old_lineno, new_lineno, render_side) = match row { + InlineRow::Context { old, new } => { + (Some(*old), Some(*new), SearchRenderSide::New) + } + InlineRow::Del { old, .. } => (Some(*old), None, SearchRenderSide::Old), + InlineRow::Add { new, .. } => (None, Some(*new), SearchRenderSide::New), + InlineRow::Gap { .. } => (None, None, SearchRenderSide::New), + }; + let search_spans = search_bg_spans(app, old_lineno, new_lineno, render_side, theme); let line = build_inline_line( view, row, @@ -2602,6 +2725,7 @@ fn render_pane_inline( theme, hscroll, text_mode, + &search_spans, ); // Cursor wins over selection on the same row (see [`Palette::selection_bg`]). let line = if is_cursor { @@ -2715,6 +2839,7 @@ mod tests { &theme, 0, DiffTextMode::Syntax, + &[], ); let without_tint = content_spans( "hello", @@ -2725,6 +2850,7 @@ mod tests { &theme, 0, DiffTextMode::Syntax, + &[], ); assert_eq!( with_tint, without_tint, @@ -2749,7 +2875,7 @@ mod tests { // 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); + let spans = content_spans("hello", None, None, &[], false, &theme, 0, mode, &[]); assert!( fgs_of(&spans) .iter() @@ -2776,6 +2902,7 @@ mod tests { &theme, 0, DiffTextMode::Tint, + &[], ); assert!( fgs_of(&spans).iter().all(|fg| *fg == Some(theme.add_fg)), @@ -2803,6 +2930,7 @@ mod tests { &theme, 0, DiffTextMode::Edit, + &[], ); let tinted: Vec<&TSpan> = spans .iter() @@ -2848,6 +2976,7 @@ mod tests { &theme, 0, DiffTextMode::Edit, + &[], ); assert!( fgs_of(&spans).iter().all(|fg| *fg == Some(theme.del_fg)), diff --git a/git-workon-review/src/search.rs b/git-workon-review/src/search.rs new file mode 100644 index 0000000..25b3edf --- /dev/null +++ b/git-workon-review/src/search.rs @@ -0,0 +1,356 @@ +//! Literal, smartcase text search over a file's pre-collapse diff rows (M11 CS3: `/` in the diff +//! view). [`compute_matches`] scans [`crate::align::AlignedRow`]s — the space BEFORE gap-collapse +//! — so a search sees hidden context exactly like it sees visible content; the caller (`app.rs`) +//! is what auto-expands a gap a match lands inside, on jump. +//! +//! Kept as pure functions over `AlignedRow`s (mirroring `align.rs`'s own style) rather than a +//! `FileView` method, so this stays independently unit-testable without building a whole +//! `App`/`FileView` fixture — `old_line`/`new_line` are taken as closures rather than a `FileView` +//! reference for the same reason. + +use crate::align::{AlignedRow, CellKind, Row}; + +/// Which side(s) of an [`AlignedRow`] a [`SearchMatch`] highlights. `Both` is a context row: the +/// same content renders on both SBS columns (and as a single [`crate::align::InlineRow::Context`] +/// row in the inline layout), so one match covers both — scanning it twice would double every +/// context-line match. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchSide { + Old, + New, + Both, +} + +/// One literal match against a file's pre-collapse row space: which [`AlignedRow`] (by index into +/// the file's `aligned` vector — the SAME `key` space [`crate::align::DisplayRow::Gap`] carries, +/// so a caller can resolve "is this match hidden, and behind which gap" via +/// [`crate::align::gap_key_for_aligned_idx`]), which side(s), the byte range `[start, end)` within +/// that line's text, and — redundantly, for cheap ordering against a cursor position without +/// re-reading `aligned` — the row's own (old, new) 1-based line numbers (`None` on the side a +/// `Del`/`Add` row's `Filler` counterpart doesn't carry). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SearchMatch { + pub aligned_idx: usize, + pub old_lineno: Option, + pub new_lineno: Option, + pub side: SearchSide, + pub start: usize, + pub end: usize, +} + +/// Smartcase, per vim's own rule: case-sensitive if `query` contains any uppercase char, +/// case-insensitive otherwise. Returns every non-overlapping literal match's byte range in +/// `haystack`, left-to-right; empty for an empty query. +/// +/// The insensitive path folds char-by-char over the ORIGINAL string rather than comparing +/// against `haystack.to_lowercase()`: whole-string lowercasing can change byte lengths +/// (`'İ'` lowercases to `"i\u{307}"`), which would shift every offset after such a char — +/// and these offsets are later used to slice the original line's text at render time, where +/// a shifted offset can land mid-codepoint and panic. +fn find_all(haystack: &str, query: &str) -> Vec<(usize, usize)> { + if query.is_empty() { + return Vec::new(); + } + if query.chars().any(|c| c.is_uppercase()) { + let mut out = Vec::new(); + let mut start = 0; + while start <= haystack.len() { + let Some(pos) = haystack[start..].find(query) else { + break; + }; + let s = start + pos; + let e = s + query.len(); + out.push((s, e)); + start = e.max(s + 1); + } + return out; + } + let needle: Vec = query.chars().flat_map(char::to_lowercase).collect(); + let mut out = Vec::new(); + let mut skip_until = 0; + for (s, _) in haystack.char_indices() { + if s < skip_until { + continue; + } + if let Some(e) = folded_match_at(haystack, s, &needle) { + out.push((s, e)); + skip_until = e.max(s + 1); + } + } + out +} + +/// Whether the case-folded `needle` matches `haystack` starting at byte offset `start` (a char +/// boundary); returns the match's END byte offset into the ORIGINAL `haystack` on success. The +/// needle must be exhausted exactly at a haystack char boundary — a needle ending partway through +/// one char's multi-char lowercase expansion (`"i"` against `'İ'` → `"i\u{307}"`) is NOT a match, +/// since there is no original-string byte offset that could represent "half of that char". +fn folded_match_at(haystack: &str, start: usize, needle: &[char]) -> Option { + let mut ni = 0; + for (off, c) in haystack[start..].char_indices() { + for fc in c.to_lowercase() { + if ni >= needle.len() || fc != needle[ni] { + return None; + } + ni += 1; + } + if ni == needle.len() { + return Some(start + off + c.len_utf8()); + } + } + None +} + +/// Scan every row of `rows` (a file's pre-collapse [`AlignedRow`] vector) for literal, smartcase +/// matches of `query`, addressing hidden-context rows exactly like visible ones. `old_line`/ +/// `new_line` fetch a 1-based line's text (mirrors [`crate::app::FileView::old_line`]/`new_line`). +/// +/// A [`CellKind::Context`] row is scanned ONCE (its new-side text — old and new agree on content +/// there) and reported as [`SearchSide::Both`]. A `Del`/`Add` row (or a paired change block's +/// `Filler` side) is scanned on whichever side actually carries a [`Row::Line`]; a row with +/// `Filler` on both sides (never produced by [`crate::align::align_file`], but not this function's +/// job to assume) simply contributes nothing. +pub fn compute_matches( + rows: &[AlignedRow], + query: &str, + old_line: impl Fn(usize) -> String, + new_line: impl Fn(usize) -> String, +) -> Vec { + if query.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + for (idx, row) in rows.iter().enumerate() { + let both_context = row.old_kind == CellKind::Context && row.new_kind == CellKind::Context; + if both_context { + if let Row::Line(n) = row.new { + let text = new_line(n); + for (start, end) in find_all(&text, query) { + out.push(SearchMatch { + aligned_idx: idx, + old_lineno: row_lineno(row.old), + new_lineno: Some(n), + side: SearchSide::Both, + start, + end, + }); + } + } + continue; + } + if let Row::Line(n) = row.old { + let text = old_line(n); + for (start, end) in find_all(&text, query) { + out.push(SearchMatch { + aligned_idx: idx, + old_lineno: Some(n), + new_lineno: row_lineno(row.new), + side: SearchSide::Old, + start, + end, + }); + } + } + if let Row::Line(n) = row.new { + let text = new_line(n); + for (start, end) in find_all(&text, query) { + out.push(SearchMatch { + aligned_idx: idx, + old_lineno: row_lineno(row.old), + new_lineno: Some(n), + side: SearchSide::New, + start, + end, + }); + } + } + } + out +} + +fn row_lineno(row: Row) -> Option { + match row { + Row::Line(n) => Some(n), + Row::Filler => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::align::{align_file, collapse_gaps}; + use crate::model::{Hunk, HunkLine, LineKind}; + + fn hl(kind: LineKind, old: Option, new: Option) -> HunkLine { + HunkLine { + kind, + content: Vec::new(), + old_lnum: old, + new_lnum: new, + missing_newline: false, + } + } + + fn hunk( + old_start: u32, + old_count: u32, + new_start: u32, + new_count: u32, + lines: Vec, + ) -> Hunk { + Hunk { + old_start, + old_count, + new_start, + new_count, + header: Vec::new(), + lines, + } + } + + #[test] + fn matches_a_paired_del_add_line_on_both_sides_independently() { + let h = hunk( + 1, + 1, + 1, + 1, + vec![ + hl(LineKind::Deletion, Some(1), None), + hl(LineKind::Addition, None, Some(1)), + ], + ); + let aligned = align_file(&[h], 1, 1).rows; + let old = |n: usize| { + if n == 1 { + "needle here".to_string() + } else { + String::new() + } + }; + let new = |n: usize| { + if n == 1 { + "no needle".to_string() + } else { + String::new() + } + }; + let matches = compute_matches(&aligned, "needle", old, new); + assert_eq!(matches.len(), 2, "one match per side: {matches:?}"); + assert!(matches.iter().any(|m| m.side == SearchSide::Old)); + assert!(matches.iter().any(|m| m.side == SearchSide::New)); + } + + #[test] + fn matches_a_context_line_once_as_both() { + let aligned = align_file(&[], 1, 1).rows; + let old = |_: usize| "same text".to_string(); + let new = |_: usize| "same text".to_string(); + let matches = compute_matches(&aligned, "text", old, new); + assert_eq!(matches.len(), 1, "a context row must match once, not twice"); + assert_eq!(matches[0].side, SearchSide::Both); + } + + #[test] + fn smartcase_is_case_sensitive_only_when_the_query_has_uppercase() { + let aligned = align_file(&[], 1, 1).rows; + let old = |_: usize| "Needle".to_string(); + let new = |_: usize| "Needle".to_string(); + assert_eq!( + compute_matches(&aligned, "needle", old, new).len(), + 1, + "an all-lowercase query is case-insensitive" + ); + assert_eq!( + compute_matches(&aligned, "Needle", old, new).len(), + 1, + "an exact-case query still matches" + ); + assert_eq!( + compute_matches(&aligned, "NEEDLE", old, new).len(), + 0, + "a query with any uppercase char turns on case-sensitivity" + ); + } + + fn context_row(n: usize) -> AlignedRow { + AlignedRow { + old: Row::Line(n), + new: Row::Line(n), + old_kind: CellKind::Context, + new_kind: CellKind::Context, + } + } + + fn change_row(n: usize) -> AlignedRow { + AlignedRow { + old: Row::Line(n), + new: Row::Line(n), + old_kind: CellKind::Del, + new_kind: CellKind::Add, + } + } + + #[test] + fn sees_matches_in_rows_that_would_collapse_into_a_gap() { + // A long unchanged run bracketed by real change rows (mirrors align.rs's own + // `change_then_context_run_then_change` gap fixture) — with a needle buried deep inside + // it, `compute_matches` must still find it, since it scans the PRE-collapse space. + let mut rows = vec![change_row(1)]; + rows.extend((2..=17).map(context_row)); + rows.push(change_row(18)); + // Confirm the fixture actually produces a gap, so this test is meaningful. + assert!(collapse_gaps(&rows) + .iter() + .any(|r| matches!(r, crate::align::DisplayRow::Gap { .. }))); + + let old = |n: usize| { + if n == 10 { + "buried needle".to_string() + } else { + "x".to_string() + } + }; + let new = |n: usize| { + if n == 10 { + "buried needle".to_string() + } else { + "x".to_string() + } + }; + let matches = compute_matches(&rows, "needle", old, new); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].new_lineno, Some(10)); + } + + #[test] + fn insensitive_offsets_stay_valid_when_lowercasing_would_shift_byte_lengths() { + // 'İ' (U+0130, 2 bytes) lowercases to "i\u{307}" (3 bytes) — whole-string lowercasing + // would shift every offset after it by one byte, handing render-time slicing a + // mid-codepoint index. The reported range must slice the ORIGINAL text cleanly. + let aligned = align_file(&[], 1, 1).rows; + let text = "İstanbul needle"; + let line = move |_: usize| text.to_string(); + let matches = compute_matches(&aligned, "needle", line, line); + assert_eq!(matches.len(), 1); + let (start, end) = (matches[0].start, matches[0].end); + assert_eq!( + &text[start..end], + "needle", + "match offsets must index the original (un-lowercased) line text" + ); + } + + #[test] + fn insensitive_needle_ending_mid_lowercase_expansion_is_not_a_match() { + // 'İ' folds to two chars ("i\u{307}"); a query consuming only the first has no valid + // end offset in the original string, so it must not match at that position — but the + // plain 'i' later in the same line still does. + let aligned = align_file(&[], 1, 1).rows; + let text = "İzmir"; + let line = move |_: usize| text.to_string(); + let matches = compute_matches(&aligned, "i", line, line); + assert_eq!(matches.len(), 1, "only the plain 'i' in 'zmir' matches"); + assert_eq!(&text[matches[0].start..matches[0].end], "i"); + } +} diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index b4da4d5..3db8c23 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -164,6 +164,12 @@ pub struct ThemeOverrides { pub cursor_unfocused_bg: Option, pub pane_header_focused_fg: Option, pub filler_fg: Option, + /// M11 CS3 (`diff-search`): every search match's highlight — a new, open-ended tint (no + /// scheme slot maps to it, so this is the ONLY way to set it; see [`Palette::search_match_bg`]'s + /// doc comment for the derived defaults). + pub search_match_bg: Option, + /// M11 CS3: the CURRENT search match's highlight, distinct from [`Self::search_match_bg`]. + pub search_current_bg: Option, } impl ThemeOverrides { @@ -441,6 +447,14 @@ pub struct Palette { /// under `auto` on a terminal whose bright-black is a vivid accent rather than a gray (the /// probed ramp interpolates toward base03, so 2/3 of a bright accent is a bright hatch). pub filler_fg: Color, + /// M11 CS3 (`diff-search`): background wash for every search match — a warm accent + /// (base0A-derived), distinct from every other row wash so it reads unambiguously over + /// del/add/cursor/selection tints it composites with (see `render.rs`'s `compose_segments` + /// bg-merge). + pub search_match_bg: Color, + /// M11 CS3: background wash for the CURRENT search match — a more saturated step of the same + /// hue as [`Palette::search_match_bg`], so "here" reads distinctly from "also matches." + pub search_current_bg: Color, /// Footer text color for an [`crate::app::Severity::Error`] notice, a pending-discard confirm /// prompt, and a Failed changeset's marker/message — a clearly-red tone (base08). Promoted /// from `render.rs`'s `FG_ERROR` const (CS2, revising ADR-029's hybrid boundary — see this @@ -540,6 +554,11 @@ impl Palette { dim: base.slot(3), gutter: base.slot(4), filler_fg: base.slot(1), + // M11 CS3: brand new, hand-tuned like the other dark-scheme washes (see `dark`'s doc + // comment on why dark tints are held explicit rather than derived) — a dim amber wash, + // brightening for the current match. + search_match_bg: Color::Rgb(90, 80, 20), + search_current_bg: Color::Rgb(150, 120, 20), // The shipped M3–M5 semantic-chrome colors, reproduced verbatim (the pixel-identity // gate — CS2 promotes these from `render.rs` consts without changing a single value). error_fg: Color::Rgb(220, 60, 60), @@ -610,6 +629,11 @@ impl Palette { dim: base.slot(3), gutter: base.slot(4), filler_fg: base.slot(1), + // M11 CS3: derived the same way as `cursor_bg`/`selection_bg` above — blend the + // scheme's amber (base0A) toward a light base00; the current match uses a shallower + // ratio (closer to the undimmed accent) so it reads more saturated than a plain match. + search_match_bg: tint_toward(base.slot(10), base00, CURSOR), + search_current_bg: tint_toward(base.slot(10), base00, EDIT), error_fg: red, warn_fg: base.slot(10), // base0A current_fg: green, @@ -678,6 +702,10 @@ impl Palette { cursor_bg: curated.cursor_bg, selection_bg: curated.selection_bg, cursor_unfocused_bg: curated.cursor_unfocused_bg, + // M11 CS3: no probed-accent counterpart to derive from (same reasoning as + // cursor/selection just above) — borrow the curated fallback's hand-tuned wash. + search_match_bg: curated.search_match_bg, + search_current_bg: curated.search_current_bg, // 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), @@ -719,8 +747,19 @@ impl Palette { /// falls to gutter glyph/structure instead, an accepted, documented degradation (see /// ADR-029'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 { + // (line, edit, staged_line, staged_edit, cursor, selection, cursor_unfocused, + // search_match, search_current) + let ( + line, + edit, + staged_line, + staged_edit, + cursor, + selection, + cursor_unfocused, + search_match, + search_current, + ) = if light { ( Color::Rgb(215, 215, 215), Color::Rgb(165, 165, 165), @@ -729,6 +768,8 @@ impl Palette { Color::Rgb(190, 190, 190), Color::Rgb(200, 200, 200), Color::Rgb(210, 210, 210), + Color::Rgb(180, 180, 180), + Color::Rgb(150, 150, 150), ) } else { ( @@ -739,6 +780,8 @@ impl Palette { Color::Rgb(65, 65, 65), Color::Rgb(55, 55, 55), Color::Rgb(45, 45, 45), + Color::Rgb(75, 75, 75), + Color::Rgb(105, 105, 105), ) }; @@ -761,6 +804,8 @@ impl Palette { cursor_bg: cursor, selection_bg: selection, cursor_unfocused_bg: cursor_unfocused, + search_match_bg: search_match, + search_current_bg: search_current, pane_header_focused_fg: Color::Reset, background: Color::Reset, foreground: Color::Reset, @@ -918,6 +963,12 @@ impl Palette { if let Some(color) = overrides.filler_fg { self.filler_fg = color; } + if let Some(color) = overrides.search_match_bg { + self.search_match_bg = color; + } + if let Some(color) = overrides.search_current_bg { + self.search_current_bg = color; + } } } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index b330c1a..b7a7ac8 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -470,6 +470,9 @@ enum Action { OutlineCollapseAll, OutlineExpandAll, OutlineFilterFocus, + SearchFocus, + SearchNext, + SearchPrev, None, } @@ -505,6 +508,9 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::ExpandAllGaps => Action::ExpandAllGaps, Command::HscrollLeft => Action::HscrollLeft, Command::HscrollRight => Action::HscrollRight, + Command::Search => Action::SearchFocus, + Command::SearchNext => Action::SearchNext, + Command::SearchPrev => Action::SearchPrev, Command::NextFile => Action::NextFile, Command::PrevFile => Action::PrevFile, Command::NextHunk => Action::NextHunk, @@ -604,6 +610,8 @@ fn action_needs_loaded_view(action: Action) -> bool { | Action::ExpandGapAll | Action::ResetGaps | Action::ExpandAllGaps + | Action::SearchNext + | Action::SearchPrev ) } @@ -675,6 +683,9 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::OutlineCollapseAll => app.outline_collapse_all(), Action::OutlineExpandAll => app.outline_expand_all(), Action::OutlineFilterFocus => app.outline_filter_focus(), + Action::SearchFocus => app.search_focus(), + Action::SearchNext => app.search_next(), + Action::SearchPrev => app.search_prev(), Action::None => {} } false @@ -690,22 +701,34 @@ enum KeyOutcome { } /// Resolve one `Key` event to a [`KeyOutcome`], given the caller has already ruled out the -/// modal cases (a pending discard confirm, the help overlay, the outline-filter input) — this is -/// cases 4-8 of `update`'s documented Esc-precedence cascade, extracted so [`update`] and -/// [`update_batch`] share the exact same resolution instead of duplicating it. +/// modal cases (a pending discard confirm, the help overlay, the outline-filter input, the +/// search prompt) — this is cases 5-9 of `update`'s documented Esc-precedence cascade, extracted +/// so [`update`] and [`update_batch`] share the exact same resolution instead of duplicating it. /// -/// Clears any showing footer notice as a side effect, exactly like `update`'s cases 4-8 do (the -/// confirm/help/filter-input modals deliberately do not — that stays in their own arms, not here). +/// Clears any showing footer notice as a side effect, exactly like `update`'s cases 5-9 do (the +/// confirm, help, and prompt modals deliberately do not — that stays in their own arms, not +/// here). fn resolve_key( app: &mut App, keymap: &Keymap, pending: &mut Vec, key: KeyEvent, ) -> KeyOutcome { - if app.selection_anchor.is_some() && key.code == KeyCode::Esc && !app.outline_focused() { - app.clear_notice(); - app.cancel_selection(); - return KeyOutcome::Handled; + if key.code == KeyCode::Esc && !app.outline_focused() { + if app.selection_anchor.is_some() { + app.clear_notice(); + app.cancel_selection(); + return KeyOutcome::Handled; + } + // M11 CS3 (`diff-search`): Esc with an ACCEPTED search active (the prompt itself already + // closed — see [`apply_search_input_key`]'s own Esc arm for the prompt-open case) clears + // it, ranked in this same tier (before the outline-focused-quit/focus-outline arms below — + // see `update`'s doc comment). + if app.search_active() { + app.clear_notice(); + app.search_clear(); + return KeyOutcome::Handled; + } } // CS2 (outline-filter): with the outline focused (the input row does NOT have capture — // that's `update`'s case-3 modal arm) and a query actively narrowing the list, Esc unwinds @@ -765,6 +788,35 @@ fn apply_filter_input_key(app: &mut App, key: KeyEvent) { } } +/// `update`'s search-prompt modal arm (M11 CS3, `diff-search`): apply one key press while the +/// diff-view search prompt has keyboard capture (see [`App::search_focused`]). Mirrors +/// [`apply_filter_input_key`]'s shape (direct `App::search_*` calls, Ctrl-chords before the +/// plain-`Char` catch-all, Alt excluded) but WITHOUT that arm's `Ctrl-c`-clears/`Down`/`Up`-move +/// extras: the search prompt has no outline-list-underneath to keep navigating while typing (the +/// plan is explicit that typing previews highlights but never moves the cursor), and `Esc` alone +/// already covers "abandon this edit." +fn apply_search_input_key(app: &mut App, key: KeyEvent) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Enter => app.search_accept(), + KeyCode::Esc => app.search_abort(), + KeyCode::Char('a') if ctrl => app.search_move_home(), + KeyCode::Char('e') if ctrl => app.search_move_end(), + KeyCode::Char('u') if ctrl => app.search_clear_to_start(), + KeyCode::Char('w') if ctrl => app.search_delete_word_back(), + KeyCode::Char(c) if !ctrl && !key.modifiers.contains(KeyModifiers::ALT) => { + app.search_insert_char(c); + } + KeyCode::Backspace => app.search_backspace(), + KeyCode::Delete => app.search_delete(), + KeyCode::Left => app.search_move_left(), + KeyCode::Right => app.search_move_right(), + KeyCode::Home => app.search_move_home(), + KeyCode::End => app.search_move_end(), + _ => {} + } +} + /// Apply one [`AppEvent`] to `app`. Returns `true` when the loop should exit (q/Esc). Resize is a /// no-op — ratatui re-measures `body_area` every frame regardless. Tick drives /// [`App::on_tick`], the M4 index watcher's poll (see the module doc). @@ -775,11 +827,12 @@ fn apply_filter_input_key(app: &mut App, key: KeyEvent) { /// tick isn't the user acting on the message. /// /// Esc precedence (highest first): a pending discard confirm > the help overlay being open > the -/// CS2 outline-filter input having capture > an active line selection (diff-focused) > an active -/// outline-filter query (outline-focused) > the outline having focus > the diff having focus with -/// the outline open > the normal key map (where Esc quits). Concretely — the home-base model: the -/// outline is where Esc always eventually lands you before it quits, unwinding any inner mode -/// (selection, filter) along the way. +/// CS2 outline-filter input having capture > the M11 CS3 search prompt having capture > an active +/// line selection OR an active search (diff-focused) > an active outline-filter query +/// (outline-focused) > the outline having focus > the diff having focus with the outline open > +/// the normal key map (where Esc quits). Concretely — the home-base model: the outline is where +/// Esc always eventually lands you before it quits, unwinding any inner mode (selection, search, +/// filter) along the way. /// /// 1. A pending discard confirm captures the keyboard FIRST (before the notice clear and the /// normal key map): `y` accepts, `n`/`Esc` cancels, and every other key is swallowed — a modal @@ -797,25 +850,31 @@ fn apply_filter_input_key(app: &mut App, key: KeyEvent) { /// (opening help while filtering isn't reachable today — `?` isn't part of the input's own key /// set — but the ordering still says which would win if that ever changed) and above every /// other case, since none of them should observe a key the filter input itself consumes. -/// 4. Otherwise, with an active line selection AND the diff focused, Esc CANCELS the selection -/// instead of moving focus or quitting (`q` still quits). This arm is guarded to defer to case -/// 6 when the outline has focus (a selection can only be active while looking at the diff, but -/// the guard keeps the precedence explicit). Other keys fall through to the normal map — -/// `j`/`k` extend the selection, `s`/`d` act on it. -/// 5. Otherwise, with the outline focused and a NON-EMPTY filter query (capture on the row list, +/// 4. Otherwise, the M11 CS3 search prompt (`/` in the diff view, while it has capture — see +/// [`App::search_focused`]) captures next, mirroring the outline-filter input's swallow: +/// typing/editing keys reach [`crate::prompt::PromptState`] (live-previewing highlights, never +/// moving the cursor), `Enter` accepts and jumps, `Esc` aborts back to whatever search (or +/// none) was active before `/` was pressed. Ranked below the outline-filter input for the same +/// "can't actually collide today, but the ordering says who'd win" reason — the two prompts +/// can never both have capture (one requires outline focus, the other diff focus). +/// 5. Otherwise, with the diff focused, Esc CANCELS an active line selection OR clears an active +/// search (selection wins if, somehow, both are active) instead of moving focus or quitting +/// (`q` still quits). This arm is guarded to defer to case 7 when the outline has focus. Other +/// keys fall through to the normal map — `j`/`k` extend a selection, `n`/`N` step a search. +/// 6. Otherwise, with the outline focused and a NON-EMPTY filter query (capture on the row list, /// not the input — that's case 3), Esc CLEARS the filter ([`App::outline_filter_clear`]) -/// instead of quitting — the outline-side mirror of case 4's unwind-the-innermost-mode rule; -/// only the next Esc reaches case 6's quit leaf. -/// 6. Otherwise, while the outline pane has focus, Esc QUITS — same terminal leaf as `q`. The +/// instead of quitting — the outline-side mirror of case 5's unwind-the-innermost-mode rule; +/// only the next Esc reaches case 7's quit leaf. +/// 7. Otherwise, while the outline pane has focus, Esc QUITS — same terminal leaf as `q`. The /// outline is home base; there's nowhere further out to walk to. -/// 7. Otherwise, with the diff focused and the outline OPEN, Esc walks outward one step: it +/// 8. Otherwise, with the diff focused and the outline OPEN, Esc walks outward one step: it /// focuses the outline (same effect as `h`/[`App::focus_outline`]) rather than quitting. -/// 8. Otherwise (diff focused, outline closed) the normal map applies, where Esc (like `q`) quits +/// 9. Otherwise (diff focused, outline closed) the normal map applies, where Esc (like `q`) quits /// — there's no outline to walk out to. /// -/// A `Key` event clears any showing footer notice before applying its own action (cases 4-8); the -/// confirm, help, and filter-input modals (cases 1-3) deliberately do not. Cases 4-8 are delegated -/// to [`resolve_key`], shared with [`update_batch`]. +/// A `Key` event clears any showing footer notice before applying its own action (cases 5-9); the +/// confirm, help, and the two prompt modals (cases 1-4) deliberately do not. Cases 5-9 are +/// delegated to [`resolve_key`], shared with [`update_batch`]. fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: AppEvent) -> bool { match event { AppEvent::Key(key) if app.pending_confirm.is_some() => { @@ -839,17 +898,22 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap apply_filter_input_key(app, key); false } + AppEvent::Key(key) if app.search_focused() => { + apply_search_input_key(app, key); + false + } AppEvent::Key(key) => match resolve_key(app, keymap, pending, key) { KeyOutcome::Handled => false, KeyOutcome::Action(action) => apply_action(app, action), }, - // CS10: all three modals swallow mouse input exactly like they swallow keys (cases 1-3 - // above) — a click/wheel while a discard confirm, the help overlay, or the CS2 - // outline-filter input is up does nothing. + // CS10: all four modals swallow mouse input exactly like they swallow keys (cases 1-4 + // above) — a click/wheel while a discard confirm, the help overlay, the CS2 outline-filter + // input, or the M11 CS3 search prompt is up does nothing. AppEvent::Mouse(_) if app.pending_confirm.is_some() || app.help_visible - || app.outline_filter_focused() => + || app.outline_filter_focused() + || app.search_focused() => { false } @@ -963,19 +1027,21 @@ fn update_batch( for event in events { match event { - // The coalescable path: no modal is up (CS2's outline-filter input included — a key - // while it has capture must reach `apply_filter_input_key` via the catch-all arm's - // `update` delegation below, never `resolve_key`/the coalescing path), and this isn't - // the selection-Esc-cancel guard (that guard is a context change — an "Esc cascade" — - // so it falls to the catch-all arm below, which flushes first and delegates the whole - // event to `update`). Notice-clearing still happens per key via `resolve_key`. + // The coalescable path: no modal is up (CS2's outline-filter input and the M11 CS3 + // search prompt both included — a key while either has capture must reach + // `apply_filter_input_key`/`apply_search_input_key` via the catch-all arm's `update` + // delegation below, never `resolve_key`/the coalescing path), and this isn't the + // selection-cancel/search-clear Esc guard (a context change — an "Esc cascade" — so it + // falls to the catch-all arm below, which flushes first and delegates the whole event + // to `update`). Notice-clearing still happens per key via `resolve_key`. AppEvent::Key(key) if app.pending_confirm.is_none() && !app.help_visible && !app.outline_filter_focused() - && !(app.selection_anchor.is_some() - && key.code == KeyCode::Esc - && !app.outline_focused()) => + && !app.search_focused() + && !(key.code == KeyCode::Esc + && !app.outline_focused() + && (app.selection_anchor.is_some() || app.search_active())) => { match resolve_key(app, keymap, pending, key) { // A coalescable nav action extends the open run when it matches in kind @@ -2152,6 +2218,84 @@ mod tests { ); } + #[test] + fn esc_ladder_search_prompt_then_active_search_then_outline_focus() { + // M11 CS3 (`diff-search`): three Esc presses in sequence, each landing on the NEXT lower + // tier of the ladder once the higher one no longer applies — the prompt-open case first + // (Esc aborts the EDIT, keeping the accepted query and its highlights), then the + // accepted-search-active case (Esc clears the search entirely), then the ordinary + // diff-with-outline-open case (Esc walks out to the outline). + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.toggle_outline(); + app.focus_diff(); + assert!(app.outline_open() && !app.outline_focused()); + + // Seed an accepted search, then reopen the prompt and type a DIFFERENT, uncommitted edit. + app.search_focus(); + app.search_insert_char('C'); // "CHANGED" — matches the new-side line + app.search_accept(); + assert!(app.search_active()); + app.search_focus(); + app.search_insert_char('x'); + assert!(app.search_focused(), "precondition: prompt has capture"); + + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + // 1. Esc while the prompt is focused: aborts the EDIT only. + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); + assert!(!quit); + assert!(!app.search_focused(), "the prompt must close"); + assert!( + app.search_active(), + "aborting the prompt must restore the previously ACCEPTED search, not clear it" + ); + + // 2. Esc with the search still active (diff focused): clears the search. + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); + assert!(!quit); + assert!( + !app.search_active(), + "this Esc must clear the active search" + ); + assert!( + !app.outline_focused(), + "clearing the search must not ALSO walk out to the outline in the same keypress" + ); + + // 3. Esc with nothing left to clear: walks out to the outline, same as the pre-existing + // ladder's diff-focused/outline-open case. + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); + assert!(!quit); + assert!( + app.outline_focused(), + "with nothing higher-precedence left, Esc must fall through to focus-outline" + ); + } + #[test] fn pending_confirm_captures_y_and_n_and_ignores_other_keys() { use git_workon_fixture::prelude::*; @@ -3757,7 +3901,7 @@ mod tests { assert!(app.outline_focused(), "list (not input) must have capture"); assert_eq!(app.outline_filter_query(), "a"); - // First Esc: unwind the filter (ladder case 5) — clear the query, do NOT quit. + // First Esc: unwind the filter (ladder case 6) — clear the query, do NOT quit. let quit = update( &mut app, &km, @@ -3770,7 +3914,7 @@ mod tests { "Esc on the list must clear the active filter query" ); - // Second Esc: the filter is gone, so the outline's terminal quit leaf (case 6) applies. + // Second Esc: the filter is gone, so the outline's terminal quit leaf (case 7) applies. let quit = update( &mut app, &km, From 43d7cfa82c0a8958440fedb21e187f87c7136078 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 25 Jul 2026 16:59:48 -0400 Subject: [PATCH 189/203] fix(review): match inline paired rows by search side for jump and tint --- git-workon-review/src/app.rs | 17 +++++++++++++---- git-workon-review/src/render.rs | 10 +++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index cbbb517..594920a 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -3839,10 +3839,19 @@ impl App { .display .iter() .position(|r| display_row_linenos(r) == target), - Layout::Inline => view - .inline - .iter() - .position(|r| inline_row_linenos(r) == target), + // Side-aware, not pair equality: the inline layout splits a paired change row's + // match (which carries BOTH linenos) into separate Del/Add rows that each carry + // only one — `Both` (a context row, which always carries both) is the only side + // where full-pair equality still applies. + Layout::Inline => view.inline.iter().position(|r| match m.side { + crate::search::SearchSide::Old => { + matches!(r, InlineRow::Del { old, .. } if Some(*old) == m.old_lineno) + } + crate::search::SearchSide::New => { + matches!(r, InlineRow::Add { new, .. } if Some(*new) == m.new_lineno) + } + crate::search::SearchSide::Both => inline_row_linenos(r) == target, + }), }; if let Some(row) = row { self.cursor = row; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 7f1c32d..6eb1906 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -787,7 +787,15 @@ fn search_bg_spans( app.search_matches() .iter() .enumerate() - .filter(|(_, m)| m.old_lineno == old_lineno && m.new_lineno == new_lineno) + // Side-aware, not pair-equality: the inline layout splits a paired change row's match + // (which carries BOTH linenos) across two rows that each carry only one, so only the + // lineno on `render_side` needs to agree — matching the full pair would silently drop + // every inline Del/Add highlight and jump target (SBS still resolves identically, since + // its caller always passes the row's own full pair). + .filter(|(_, m)| match render_side { + SearchRenderSide::Old => old_lineno.is_some() && m.old_lineno == old_lineno, + SearchRenderSide::New => new_lineno.is_some() && m.new_lineno == new_lineno, + }) .filter(|(_, m)| match (m.side, render_side) { (SearchSide::Both, _) => true, (SearchSide::Old, SearchRenderSide::Old) => true, From 61dfe675d6abf248b9578a1af8cb6fdd265f56ab Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 25 Jul 2026 17:00:59 -0400 Subject: [PATCH 190/203] fix(review): only tint search matches in the focused pane --- git-workon-review/src/app.rs | 2 +- git-workon-review/src/render.rs | 28 +++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 594920a..1b7ae6c 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -2027,7 +2027,7 @@ impl App { /// The role whose view [`Self::cursor`]/[`Self::scroll`] currently drive for file `idx`: the /// single effective role, or the focused split pane's role. - fn focused_role_for(&self, idx: usize) -> Role { + pub(crate) fn focused_role_for(&self, idx: usize) -> Role { match self.effective_zoom_for(idx) { EffectiveZoom::Single(role) => role, EffectiveZoom::Split => self.split_focus_role(), diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 6eb1906..cf537cd 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -2379,6 +2379,11 @@ fn render_pane_sbs( let hscroll = app.hscroll; // `workon.review.diff.text` (CS11) — read once per frame, same posture as `hscroll` above. let text_mode = app.diff_text; + // `App::search_matches` is computed only against the FOCUSED pane's view (see + // `App::recompute_search`) — painting them on the other split pane too can slice its text at + // byte offsets that don't land on a char boundary there. Gate highlighting to the pane whose + // view the matches were actually computed against. + let search_focused_here = role == app.focused_role_for(idx); let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), old_area); @@ -2454,10 +2459,16 @@ fn render_pane_sbs( Row::Line(n) => Some(n), Row::Filler => None, }; - let old_search = - search_bg_spans(app, old_lineno, new_lineno, SearchRenderSide::Old, theme); - let new_search = - search_bg_spans(app, old_lineno, new_lineno, SearchRenderSide::New, theme); + let old_search = if search_focused_here { + search_bg_spans(app, old_lineno, new_lineno, SearchRenderSide::Old, theme) + } else { + Vec::new() + }; + let new_search = if search_focused_here { + search_bg_spans(app, old_lineno, new_lineno, SearchRenderSide::New, theme) + } else { + Vec::new() + }; let old_line = build_pane_line( view, @@ -2654,6 +2665,9 @@ fn render_pane_inline( let hscroll = app.hscroll; // `workon.review.diff.text` (CS11) — read once per frame, same posture as `hscroll` above. let text_mode = app.diff_text; + // See `render_pane_sbs`'s identical comment — gate search highlighting to the pane the + // matches were actually computed against. + let search_focused_here = role == app.focused_role_for(idx); let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), area); @@ -2722,7 +2736,11 @@ fn render_pane_inline( InlineRow::Add { new, .. } => (None, Some(*new), SearchRenderSide::New), InlineRow::Gap { .. } => (None, None, SearchRenderSide::New), }; - let search_spans = search_bg_spans(app, old_lineno, new_lineno, render_side, theme); + let search_spans = if search_focused_here { + search_bg_spans(app, old_lineno, new_lineno, render_side, theme) + } else { + Vec::new() + }; let line = build_inline_line( view, row, From ff9821f8f239fc8f3d25aadc7d54cd4960822311 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 25 Jul 2026 17:01:22 -0400 Subject: [PATCH 191/203] fix(review): recompute search when split focus toggles --- git-workon-review/src/app.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 1b7ae6c..6d103e3 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -2631,6 +2631,9 @@ impl App { SplitPane::Staged => SplitPane::Unstaged, }; self.derive_scroll(); + // `current_view_ref` now resolves to the OTHER pane's view — recompute so matches, + // jumps, and gap expansion are driven by the newly-focused pane, not a stale one. + self.recompute_search(); } /// Reshape onto changeset `target` (clamped into range), landing on file `file_idx` of ITS From 13c9574a63a25a729d31bdb3c13944ee376e3e82 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 25 Jul 2026 17:03:12 -0400 Subject: [PATCH 192/203] fix(review): order search accept by aligned row not lineno --- git-workon-review/src/app.rs | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 6d103e3..c971764 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -3744,27 +3744,39 @@ impl App { self.recompute_search(); } - /// The cursor's own (old, new) lineno pair, preferring the new-side lineno like - /// [`Self::restore_position`]'s convention — the ordering key [`Self::first_match_at_or_after_cursor`] - /// compares [`crate::search::SearchMatch`]'s own (old, new) pair against. - fn cursor_lineno(&self) -> Option { + /// The [`crate::align::AlignedRow`] index (into [`FileView::aligned`], the SAME space + /// [`crate::search::SearchMatch::aligned_idx`] addresses) the cursor's current row corresponds + /// to — resolved by lineno rather than by position in the active layout's row vector, since + /// the inline layout's per-run Del-then-Add reordering (see `align::inline_rows`) means a + /// row's POSITION there does NOT correspond to `aligned_idx` order the way it does in the SBS + /// layout. `None` when the view or the cursor's row can't be resolved. + fn cursor_aligned_idx(&self) -> Option { let view = self.current_view_ref()?; let (old, new) = match self.layout { Layout::Sbs => display_row_linenos(view.display.get(self.cursor)?), Layout::Inline => inline_row_linenos(view.inline.get(self.cursor)?), }; - new.or(old) + view.aligned.iter().position(|r| { + let (row_old, row_new) = (row_lineno(r.old), row_lineno(r.new)); + match (old, new) { + (Some(_), Some(_)) => row_old == old && row_new == new, + (Some(_), None) => row_old == old, + (None, Some(_)) => row_new == new, + (None, None) => false, + } + }) } /// The first [`Self::search_matches`] index whose row is at-or-after the cursor's own - /// position, by (new-preferring) lineno — [`Self::search_accept`]'s jump target. `None` when - /// every match lies strictly before the cursor (the wrap case its caller handles). + /// position, in aligned-row order (not lineno — old/new linenos diverge in files with net + /// insertions/deletions, which can otherwise skip or misorder del-side matches) — + /// [`Self::search_accept`]'s jump target. `None` when every match lies strictly before the + /// cursor (the wrap case its caller handles). fn first_match_at_or_after_cursor(&self) -> Option { - let cursor_line = self.cursor_lineno().unwrap_or(0); - self.search_matches.iter().position(|m| { - let line = m.new_lineno.or(m.old_lineno).unwrap_or(0); - line >= cursor_line - }) + let cursor_idx = self.cursor_aligned_idx().unwrap_or(0); + self.search_matches + .iter() + .position(|m| m.aligned_idx >= cursor_idx) } /// `n`/`N` (contextual): jump to the next/previous search match when a search is active, From 6ac04885300ec9269ffbdec6c0b5087d585765f3 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 25 Jul 2026 17:05:32 -0400 Subject: [PATCH 193/203] refactor(review): share context-run measurement in align --- git-workon-review/src/align.rs | 176 ++++++++++++++++++--------------- 1 file changed, 94 insertions(+), 82 deletions(-) diff --git a/git-workon-review/src/align.rs b/git-workon-review/src/align.rs index 71db9de..436993c 100644 --- a/git-workon-review/src/align.rs +++ b/git-workon-review/src/align.rs @@ -278,112 +278,148 @@ fn collapse_gaps_inner( continue; } - // Measure the full run of context rows starting at i. - let run_start = i; - let mut run_end = i; - while run_end < rows.len() && is_context(&rows[run_end]) { - run_end += 1; - } - let run_len = run_end - run_start; - - // Keep `context` lines of lead-in unless this run touches the start of the file (no - // hunk before it to lead away from) or the end of the file (no hunk after it to lead - // into) — those edges get no filler on the missing side. - let keep_before = if run_start == 0 { 0 } else { context }; - let keep_after = if run_end == rows.len() { 0 } else { context }; + // `i` is always a run's own start here: the loop only ever reaches this branch right + // after either the start of `rows` or a non-context row pushed one at a time above. + let run = measure_context_run(rows, i, context) + .expect("i is the start of a context run, checked above"); - if (keep_before == 0 && keep_after == 0) || run_len <= keep_before + keep_after { + if !run.collapse_eligible() { // Either too short to collapse, or (keep_before == keep_after == 0) this run is // the entire row list — a wholly unchanged file with no hunk on either side to // contextualize. Emit every row, no gap. - for row in &rows[run_start..run_end] { + for row in &rows[run.start..run.end] { out.push(DisplayRow::Row(*row)); } - i = run_end; + i = run.end; continue; } // This run collapses to a gap (before any expansion is applied) — the key is stable // across future expansion requests, so compute it once here. - let key = run_start; + let key = run.start; let expansion = expansions.get(&key).copied().unwrap_or_default(); - let effective_before = (keep_before + expansion.before).min(run_len); - let effective_after = (keep_after + expansion.after).min(run_len - effective_before); + let run_len = run.len(); + let effective_before = (run.keep_before + expansion.before).min(run_len); + let effective_after = (run.keep_after + expansion.after).min(run_len - effective_before); if expansion.full || effective_before + effective_after >= run_len { // The expansion consumes the whole run (or was asked to): no gap left worth // collapsing, emit every row. - for row in &rows[run_start..run_end] { + for row in &rows[run.start..run.end] { out.push(DisplayRow::Row(*row)); } } else { - for row in &rows[run_start..run_start + effective_before] { + for row in &rows[run.start..run.start + effective_before] { out.push(DisplayRow::Row(*row)); } let skipped = run_len - effective_before - effective_after; out.push(DisplayRow::Gap { key, skipped }); - for row in &rows[run_end - effective_after..run_end] { + for row in &rows[run.end - effective_after..run.end] { out.push(DisplayRow::Row(*row)); } } - i = run_end; + i = run.end; } out } -/// The currently-hidden [`AlignedRow`] sub-range `[start, end)` for the gap keyed `key`, given -/// its current `expansion` (if any) — used by [`crate::app::FileView::scope_expand_gap`] (CS9) to -/// measure how much of a gap's hidden run a candidate tree-sitter scope range would additionally -/// uncover. `None` when `key` no longer denotes an actual gap: not a context-run start, the run is -/// too short to have collapsed in the first place, or `expansion` already reveals the whole run. -/// -/// Mirrors the run-measuring steps in [`collapse_gaps_inner`] (same `keep_before`/`keep_after`/ -/// `effective_before`/`effective_after` derivation) rather than sharing code with it, because that -/// function additionally needs `run_end` and the row slices themselves to emit `DisplayRow`s, -/// while this one only needs the hidden index range for a `key` a caller already has — keep both -/// in sync if the collapse rule ever changes. -pub(crate) fn gap_hidden_range( +/// A maximal run of [`CellKind::Context`] rows in `[start, end)`, and how many of its own rows +/// must stay visible at each edge (`keep_before`/`keep_after` — `0` at a run touching the start +/// or end of the file, where there's no hunk on that side to lead away from/into). +struct ContextRun { + start: usize, + end: usize, + keep_before: usize, + keep_after: usize, +} + +impl ContextRun { + fn len(&self) -> usize { + self.end - self.start + } + + /// Whether this run has more rows than its own `keep_before`/`keep_after` window needs kept + /// visible — the single collapse-eligibility test [`collapse_gaps_inner`], [`gap_hidden_range`], + /// and [`gap_key_for_aligned_idx`] each independently re-derived before this was extracted. + fn collapse_eligible(&self) -> bool { + !(self.keep_before == 0 && self.keep_after == 0) + && self.len() > self.keep_before + self.keep_after + } +} + +/// The maximal context run containing `rows[idx_in_run]`, with its `keep_before`/`keep_after` +/// edges resolved against `context` — the run-boundary + keep-before/after derivation shared by +/// [`collapse_gaps_inner`], [`gap_hidden_range`], and [`gap_key_for_aligned_idx`] (previously three +/// independent copies of this same scan). `context` is threaded through rather than hardcoded to +/// [`CONTEXT_LINES`] since [`collapse_gaps_inner`]'s test-only entry point +/// ([`collapse_gaps_with`]) takes an explicit count. `None` when `idx_in_run` isn't inside a +/// context run at all (out of bounds, or the row there isn't [`CellKind::Context`] on both +/// sides). +fn measure_context_run( rows: &[AlignedRow], - key: usize, - expansions: &HashMap, -) -> Option<(usize, usize)> { + idx_in_run: usize, + context: usize, +) -> Option { let is_context = |row: &AlignedRow| { matches!( (row.old_kind, row.new_kind), (CellKind::Context, CellKind::Context) ) }; - - let run_start = key; - if run_start >= rows.len() || !is_context(&rows[run_start]) { + if idx_in_run >= rows.len() || !is_context(&rows[idx_in_run]) { return None; } - let mut run_end = run_start; - while run_end < rows.len() && is_context(&rows[run_end]) { - run_end += 1; + let mut start = idx_in_run; + while start > 0 && is_context(&rows[start - 1]) { + start -= 1; + } + let mut end = idx_in_run + 1; + while end < rows.len() && is_context(&rows[end]) { + end += 1; } - let run_len = run_end - run_start; + let keep_before = if start == 0 { 0 } else { context }; + let keep_after = if end == rows.len() { 0 } else { context }; + Some(ContextRun { + start, + end, + keep_before, + keep_after, + }) +} - let keep_before = if run_start == 0 { 0 } else { CONTEXT_LINES }; - let keep_after = if run_end == rows.len() { - 0 - } else { - CONTEXT_LINES - }; - if (keep_before == 0 && keep_after == 0) || run_len <= keep_before + keep_after { +/// The currently-hidden [`AlignedRow`] sub-range `[start, end)` for the gap keyed `key`, given +/// its current `expansion` (if any) — used by [`crate::app::FileView::scope_expand_gap`] (CS9) to +/// measure how much of a gap's hidden run a candidate tree-sitter scope range would additionally +/// uncover. `None` when `key` no longer denotes an actual gap: not a context-run start, the run is +/// too short to have collapsed in the first place, or `expansion` already reveals the whole run. +/// +/// Uses [`measure_context_run`] (`key` is always the run's own start — see the doc above) for the +/// run-boundary/`keep_before`/`keep_after` derivation [`collapse_gaps_inner`] and +/// [`gap_key_for_aligned_idx`] share it with, then re-derives `effective_before`/`effective_after` +/// against `expansion` itself, since that step also needs `run_end`/the row slices +/// [`collapse_gaps_inner`] emits `DisplayRow`s from — keep both in sync if the collapse rule ever +/// changes. +pub(crate) fn gap_hidden_range( + rows: &[AlignedRow], + key: usize, + expansions: &HashMap, +) -> Option<(usize, usize)> { + let run = measure_context_run(rows, key, CONTEXT_LINES)?; + if !run.collapse_eligible() { return None; } + let run_len = run.len(); let expansion = expansions.get(&key).copied().unwrap_or_default(); - let effective_before = (keep_before + expansion.before).min(run_len); - let effective_after = (keep_after + expansion.after).min(run_len - effective_before); + let effective_before = (run.keep_before + expansion.before).min(run_len); + let effective_after = (run.keep_after + expansion.after).min(run_len - effective_before); if expansion.full || effective_before + effective_after >= run_len { return None; } - Some((run_start + effective_before, run_end - effective_after)) + Some((run.start + effective_before, run.end - effective_after)) } /// The gap `key` (the hidden run's start index, matching [`DisplayRow::Gap`]'s own `key`) whose @@ -395,35 +431,11 @@ pub(crate) fn gap_hidden_range( /// currently visible needs this reverse lookup — "which gap, if any, would need expanding to /// reveal this row" — before [`crate::app::FileView::expand_gap`] can be called with the right key. pub(crate) fn gap_key_for_aligned_idx(rows: &[AlignedRow], aligned_idx: usize) -> Option { - let is_context = |row: &AlignedRow| { - matches!( - (row.old_kind, row.new_kind), - (CellKind::Context, CellKind::Context) - ) - }; - if aligned_idx >= rows.len() || !is_context(&rows[aligned_idx]) { - return None; - } - let mut run_start = aligned_idx; - while run_start > 0 && is_context(&rows[run_start - 1]) { - run_start -= 1; - } - let mut run_end = aligned_idx + 1; - while run_end < rows.len() && is_context(&rows[run_end]) { - run_end += 1; - } - let run_len = run_end - run_start; - - let keep_before = if run_start == 0 { 0 } else { CONTEXT_LINES }; - let keep_after = if run_end == rows.len() { - 0 - } else { - CONTEXT_LINES - }; - if (keep_before == 0 && keep_after == 0) || run_len <= keep_before + keep_after { + let run = measure_context_run(rows, aligned_idx, CONTEXT_LINES)?; + if !run.collapse_eligible() { return None; } - Some(run_start) + Some(run.start) } /// One row of the inline (unified, single-column) display. From 596245c9eb1bc4c04058eff1e1b0c906fdcee1bb Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 25 Jul 2026 17:06:55 -0400 Subject: [PATCH 194/203] refactor(review): share prompt key decoding between filter and search --- git-workon-review/src/tui.rs | 122 ++++++++++++++++++++++++----------- 1 file changed, 83 insertions(+), 39 deletions(-) diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index b7a7ac8..feb671d 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -762,59 +762,103 @@ fn resolve_key( /// `Ctrl-e`/`Ctrl-n`/`Ctrl-p`/`Ctrl-u`/`Ctrl-w` never fall through and get inserted as literal /// text. `Alt`-modified chars are excluded from the catch-all too (there is no bound behavior for /// them here, and inserting an Alt-chorded char as plain text would be surprising). -fn apply_filter_input_key(app: &mut App, key: KeyEvent) { +/// One readline-style prompt edit, decoded from a key event by [`prompt_edit_for_key`] — +/// everything [`apply_filter_input_key`] and [`apply_search_input_key`] do that ISN'T specific to +/// which prompt is focused (their own Enter/Esc, and the filter's outline-list-navigation extras). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PromptEdit { + InsertChar(char), + Backspace, + Delete, + MoveLeft, + MoveRight, + MoveHome, + MoveEnd, + ClearToStart, + DeleteWordBack, +} + +/// Decode a key event into the [`PromptEdit`] it means, or `None` for a key neither prompt modal +/// arm handles (left for that arm's own extras, or unbound). Shared by +/// [`apply_filter_input_key`]/[`apply_search_input_key`] — previously two independent copies of +/// this same decode (ctrl-chord extraction, `Ctrl-a`/`e`/`u`/`w`, the plain-`Char` guard excluding +/// ctrl/alt, `Backspace`/`Delete`/`Left`/`Right`/`Home`/`End`). +fn prompt_edit_for_key(key: KeyEvent) -> Option { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { - KeyCode::Enter | KeyCode::Esc => app.outline_filter_unfocus(), - KeyCode::Char('c') if ctrl => app.outline_filter_clear(), - KeyCode::Char('a') if ctrl => app.outline_filter_move_home(), - KeyCode::Char('e') if ctrl => app.outline_filter_move_end(), - KeyCode::Char('u') if ctrl => app.outline_filter_clear_to_start(), - KeyCode::Char('w') if ctrl => app.outline_filter_delete_word_back(), - KeyCode::Char('n') if ctrl => app.outline_move_by(1), - KeyCode::Char('p') if ctrl => app.outline_move_by(-1), + KeyCode::Char('a') if ctrl => Some(PromptEdit::MoveHome), + KeyCode::Char('e') if ctrl => Some(PromptEdit::MoveEnd), + KeyCode::Char('u') if ctrl => Some(PromptEdit::ClearToStart), + KeyCode::Char('w') if ctrl => Some(PromptEdit::DeleteWordBack), KeyCode::Char(c) if !ctrl && !key.modifiers.contains(KeyModifiers::ALT) => { - app.outline_filter_insert_char(c); + Some(PromptEdit::InsertChar(c)) } - KeyCode::Backspace => app.outline_filter_backspace(), - KeyCode::Delete => app.outline_filter_delete(), - KeyCode::Left => app.outline_filter_move_left(), - KeyCode::Right => app.outline_filter_move_right(), - KeyCode::Home => app.outline_filter_move_home(), - KeyCode::End => app.outline_filter_move_end(), - KeyCode::Down => app.outline_move_by(1), - KeyCode::Up => app.outline_move_by(-1), + KeyCode::Backspace => Some(PromptEdit::Backspace), + KeyCode::Delete => Some(PromptEdit::Delete), + KeyCode::Left => Some(PromptEdit::MoveLeft), + KeyCode::Right => Some(PromptEdit::MoveRight), + KeyCode::Home => Some(PromptEdit::MoveHome), + KeyCode::End => Some(PromptEdit::MoveEnd), + _ => None, + } +} + +/// `update`'s case-3 modal arm: apply one key press while the CS2 outline-filter INPUT has +/// keyboard capture (see [`App::outline_filter_focused`]). Handles its own Enter/Esc and the +/// outline-list-navigation extras (`Ctrl-c`/`Ctrl-n`/`Ctrl-p`/`Down`/`Up`) directly, then +/// delegates every other key to [`prompt_edit_for_key`] — every branch calls straight into an +/// `App::outline_filter_*` method, no [`Action`]/[`map_key`] indirection, matching +/// [`crate::prompt`]'s own module doc that this is readline muscle memory, not a rebindable action +/// set. +fn apply_filter_input_key(app: &mut App, key: KeyEvent) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Enter | KeyCode::Esc => return app.outline_filter_unfocus(), + KeyCode::Char('c') if ctrl => return app.outline_filter_clear(), + KeyCode::Char('n') if ctrl => return app.outline_move_by(1), + KeyCode::Char('p') if ctrl => return app.outline_move_by(-1), + KeyCode::Down => return app.outline_move_by(1), + KeyCode::Up => return app.outline_move_by(-1), _ => {} } + match prompt_edit_for_key(key) { + Some(PromptEdit::InsertChar(c)) => app.outline_filter_insert_char(c), + Some(PromptEdit::Backspace) => app.outline_filter_backspace(), + Some(PromptEdit::Delete) => app.outline_filter_delete(), + Some(PromptEdit::MoveLeft) => app.outline_filter_move_left(), + Some(PromptEdit::MoveRight) => app.outline_filter_move_right(), + Some(PromptEdit::MoveHome) => app.outline_filter_move_home(), + Some(PromptEdit::MoveEnd) => app.outline_filter_move_end(), + Some(PromptEdit::ClearToStart) => app.outline_filter_clear_to_start(), + Some(PromptEdit::DeleteWordBack) => app.outline_filter_delete_word_back(), + None => {} + } } /// `update`'s search-prompt modal arm (M11 CS3, `diff-search`): apply one key press while the /// diff-view search prompt has keyboard capture (see [`App::search_focused`]). Mirrors -/// [`apply_filter_input_key`]'s shape (direct `App::search_*` calls, Ctrl-chords before the -/// plain-`Char` catch-all, Alt excluded) but WITHOUT that arm's `Ctrl-c`-clears/`Down`/`Up`-move -/// extras: the search prompt has no outline-list-underneath to keep navigating while typing (the -/// plan is explicit that typing previews highlights but never moves the cursor), and `Esc` alone -/// already covers "abandon this edit." +/// [`apply_filter_input_key`]'s shape (own Enter/Esc first, then [`prompt_edit_for_key`]) but +/// WITHOUT that arm's outline-list-navigation extras: the search prompt has no outline-list- +/// underneath to keep navigating while typing (the plan is explicit that typing previews +/// highlights but never moves the cursor), and `Esc` alone already covers "abandon this edit." fn apply_search_input_key(app: &mut App, key: KeyEvent) { - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { - KeyCode::Enter => app.search_accept(), - KeyCode::Esc => app.search_abort(), - KeyCode::Char('a') if ctrl => app.search_move_home(), - KeyCode::Char('e') if ctrl => app.search_move_end(), - KeyCode::Char('u') if ctrl => app.search_clear_to_start(), - KeyCode::Char('w') if ctrl => app.search_delete_word_back(), - KeyCode::Char(c) if !ctrl && !key.modifiers.contains(KeyModifiers::ALT) => { - app.search_insert_char(c); - } - KeyCode::Backspace => app.search_backspace(), - KeyCode::Delete => app.search_delete(), - KeyCode::Left => app.search_move_left(), - KeyCode::Right => app.search_move_right(), - KeyCode::Home => app.search_move_home(), - KeyCode::End => app.search_move_end(), + KeyCode::Enter => return app.search_accept(), + KeyCode::Esc => return app.search_abort(), _ => {} } + match prompt_edit_for_key(key) { + Some(PromptEdit::InsertChar(c)) => app.search_insert_char(c), + Some(PromptEdit::Backspace) => app.search_backspace(), + Some(PromptEdit::Delete) => app.search_delete(), + Some(PromptEdit::MoveLeft) => app.search_move_left(), + Some(PromptEdit::MoveRight) => app.search_move_right(), + Some(PromptEdit::MoveHome) => app.search_move_home(), + Some(PromptEdit::MoveEnd) => app.search_move_end(), + Some(PromptEdit::ClearToStart) => app.search_clear_to_start(), + Some(PromptEdit::DeleteWordBack) => app.search_delete_word_back(), + None => {} + } } /// Apply one [`AppEvent`] to `app`. Returns `true` when the loop should exit (q/Esc). Resize is a From dfe581945c53743ef919622fe3e17d3120976ec0 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 27 Jul 2026 15:03:40 -0400 Subject: [PATCH 195/203] fix(review): bound search-jump gap reveal and keep current match --- git-workon-review/src/app.rs | 306 +++++++++++++++++++++++++++++++---- 1 file changed, 273 insertions(+), 33 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index c971764..caa3c2a 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -19,7 +19,7 @@ use workon::{Changeset, ChangesetSpan}; use crate::acquire::{ChangesetDiff, WorktreeDiffs}; use crate::align::{ align_file, collapse_gaps, collapse_gaps_with_expansions, gap_hidden_range, inline_rows, - AlignedRow, CellKind, DisplayRow, GapExpansion, InlineRow, Row, + AlignedRow, CellKind, DisplayRow, GapExpansion, InlineRow, Row, CONTEXT_LINES, }; use crate::apply::{Git2Applier, StageVerb}; use crate::config::RawViewConfig; @@ -1475,11 +1475,14 @@ pub struct App { search_focused: bool, /// The CURRENT search text's matches (the live prompt buffer's while [`Self::search_focused`], /// else [`Self::search_query`]'s) against the focused pane's file, in file order — recomputed - /// by [`Self::recompute_search`] on every trigger the M11 CS3 plan names: prompt edits, accept, - /// abort, file/changeset switch, refresh, layout/zoom change. + /// by [`Self::recompute_search`]/[`Self::recompute_search_keep_current`] on every trigger the + /// M11 CS3 plan names: prompt edits, accept, abort, file/changeset switch, refresh, zoom + /// change, layout change. search_matches: Vec, /// Index into [`Self::search_matches`] of the match the cursor is currently parked on — - /// `None` while merely previewing (typing, before `Enter`) or when there's nothing to park on. + /// `None` while merely previewing (typing, before `Enter`), when there's nothing to park on, or + /// after a trigger with no "cursor is parked on match N" claim to make (see + /// [`Self::recompute_search`] vs [`Self::recompute_search_keep_current`]). search_current: Option, } @@ -3628,12 +3631,32 @@ impl App { /// Recompute [`Self::search_matches`] from [`Self::active_search_text`] against the FOCUSED /// pane's current file view — called on every trigger the M11 CS3 plan names: every prompt /// edit (live preview), accept/abort, file/changeset switch and refresh (both funnel through - /// [`Self::reset_panes`]), and a layout/zoom change (harmless to re-run even when the match - /// content can't have changed — matches address the layout-agnostic `AlignedRow` space). - /// [`Self::search_current`] always resets to `None` here: a fresh match list has no "the - /// cursor is parked on match N" claim to make until [`Self::search_accept`]/ - /// [`Self::search_next`]/[`Self::search_prev`] jumps to one. + /// [`Self::reset_panes`]), and a layout change (harmless to re-run even when the match content + /// can't have changed — matches address the layout-agnostic `AlignedRow` space). + /// [`Self::search_current`] resets to `None` here: a query edit, accept/abort, or a + /// file/changeset switch/refresh has no "the cursor is parked on match N" claim left to make + /// until [`Self::search_accept`]/[`Self::search_next`]/[`Self::search_prev`] jumps to one. fn recompute_search(&mut self) { + self.recompute_search_inner(None); + } + + /// [`Self::recompute_search`], but for a trigger that reshapes ONLY how the match list resolves + /// to rows — not the match list's own content or which file it's against (`toggle_layout` and + /// its `reload_view_config` echo, both same-file layout flips). Carries + /// [`Self::search_current`] across: captures the currently-current [`crate::search::SearchMatch`] + /// by value before recomputing, then re-finds its index in the new (address-identical) list and + /// restores it if still present — `SearchMatch` is `Copy + PartialEq`, so this is cheap. A + /// zoom change does NOT use this path even though it also funnels through `reset_panes`: it + /// swaps which role's (staged/unstaged/combined) content is current, a genuinely different match + /// list, so the plain reset in [`Self::recompute_search`] is the correct behavior there too. + fn recompute_search_keep_current(&mut self) { + let prior = self + .search_current + .and_then(|i| self.search_matches.get(i).copied()); + self.recompute_search_inner(prior); + } + + fn recompute_search_inner(&mut self, prior_current: Option) { self.search_current = None; let Some(text) = self.active_search_text() else { self.search_matches.clear(); @@ -3648,6 +3671,9 @@ impl App { Some(view) => view.search_matches(&text), None => Vec::new(), }; + if let Some(m) = prior_current { + self.search_current = self.search_matches.iter().position(|&x| x == m); + } } /// `Enter` while the prompt is focused: commit the buffer as the accepted query, close the @@ -3830,6 +3856,12 @@ impl App { /// match's (old, new) lineno pair and land there. `wrapped` raises the footer notice the plan /// calls for; a match whose row can't be located post-expansion (should be unreachable once /// expanded) leaves the cursor where it was rather than panicking. + /// + /// The reveal is BOUNDED, not full: widen only whichever gap edge sits nearer the match, by + /// just enough rows to surface it plus a small [`crate::align::CONTEXT_LINES`] margin, rather + /// than dumping the entire hidden run (`full: true`) the way an earlier round did. `expand_gap` + /// accumulates, so repeated jumps into the same gap widen it further rather than resetting — + /// deliberately not reset here. fn jump_to_search_match(&mut self, idx: usize, wrapped: bool) { let Some(&m) = self.search_matches.get(idx) else { return; @@ -3838,10 +3870,22 @@ impl App { if let Some(view) = self.current_view() { if let Some(key) = crate::align::gap_key_for_aligned_idx(&view.aligned, m.aligned_idx) { - let hidden = crate::align::gap_hidden_range(&view.aligned, key, &view.expansions) - .is_some_and(|(start, end)| m.aligned_idx >= start && m.aligned_idx < end); - if hidden { - view.expand_gap(key, 0, 0, true); + if let Some((start, end)) = gap_hidden_range(&view.aligned, key, &view.expansions) { + if m.aligned_idx >= start && m.aligned_idx < end { + // Reveal from whichever edge is nearer the match: `dist_to_start` rows lie + // between the hidden range's leading edge and the match (inclusive of the + // match's own row), `dist_to_end` the same from the trailing edge. Widen + // that edge by `dist + 1` (through the match's row) plus a small context + // margin, so the reveal reads like the rest of the file rather than + // stopping dead on the match itself. + let dist_to_start = m.aligned_idx - start; + let dist_to_end = end - 1 - m.aligned_idx; + if dist_to_start <= dist_to_end { + view.expand_gap(key, dist_to_start + 1 + CONTEXT_LINES, 0, false); + } else { + view.expand_gap(key, 0, dist_to_end + 1 + CONTEXT_LINES, false); + } + } } } } @@ -4564,10 +4608,11 @@ impl App { }; } self.derive_scroll(); - // M11 CS3: named as a recompute trigger by the plan even though the match ADDRESSES - // (aligned-space) can't actually change here — only which display/inline row each one - // resolves to. Cheap to re-run regardless (see [`Self::recompute_search`]'s doc comment). - self.recompute_search(); + // M11 CS3: the match ADDRESSES (aligned-space) can't actually change here — only which + // display/inline row each one resolves to — so carry `search_current` across rather than + // losing the "you are on match N" highlight to a same-file layout flip. See + // [`Self::recompute_search_keep_current`]'s doc comment. + self.recompute_search_keep_current(); } /// Set the render layout directly — the config-startup (CS7) counterpart to @@ -4725,7 +4770,9 @@ impl App { }; } self.derive_scroll(); - self.recompute_search(); + // Mirrors `toggle_layout`'s tail: same-file layout flip, so carry `search_current` + // across rather than losing it (see [`Self::recompute_search_keep_current`]). + self.recompute_search_keep_current(); } if self.outline.mode != outline_mode_before || self.outline.order != outline_order_before { @@ -12542,18 +12589,20 @@ mod tests { } #[test] - fn search_accept_jumps_to_the_first_match_and_auto_expands_its_gap() { + fn search_accept_jumps_to_the_first_match_and_reveals_its_gap_around_it() { let fixture = two_hunks_with_a_buried_needle_fixture(); let mut app = app_from_fixture(&fixture); app.open_current(); - assert!( - app.current_view_ref() - .unwrap() - .display - .iter() - .any(|r| matches!(r, DisplayRow::Gap { .. })), - "precondition: the fixture's wide context run must start out collapsed" - ); + let skipped_before = app + .current_view_ref() + .unwrap() + .display + .iter() + .find_map(|r| match r { + DisplayRow::Gap { skipped, .. } => Some(*skipped), + _ => None, + }) + .expect("precondition: the fixture's wide context run must start out collapsed"); app.search_focus(); for c in "needle".chars() { @@ -12562,24 +12611,110 @@ mod tests { app.search_accept(); let view = app.current_view_ref().unwrap(); + let skipped_after = view.display.iter().find_map(|r| match r { + DisplayRow::Gap { skipped, .. } => Some(*skipped), + _ => None, + }); assert!( - !view - .display - .iter() - .any(|r| matches!(r, DisplayRow::Gap { .. })), - "jumping to a match buried in the gap must fully reveal it: {:?}", + skipped_after.is_some_and(|skipped| skipped < skipped_before), + "the reveal is BOUNDED, not full: some of the run must still be collapsed, just \ + less of it than before (was {skipped_before} skipped, now {skipped_after:?}): {:?}", view.display ); match view.display[app.cursor] { DisplayRow::Row(row) => { assert_eq!(row.old, Row::Line(21), "needle_line is old-side line 21"); } - other => panic!("expected the cursor to land on the needle's row, got {other:?}"), + other => panic!( + "expected the cursor to land on the needle's row (revealed by the bounded \ + expansion), got {other:?}" + ), } assert!(!app.search_focused(), "accept must close the prompt"); assert!(app.search_active()); } + /// One wide hidden context run (50 lines) with two needles far apart inside it — `needleA` + /// near the leading edge (line 10), `needleB` near the trailing edge (line 35) — so jumping to + /// each in turn widens the SAME gap from opposite edges. CS3's "repeated jumps into one gap + /// accumulate rather than reset" fixture. + fn one_gap_with_two_needles_fixture() -> Fixture { + let mut committed = String::from("OLD_HUNK_A\n"); + let mut modified = String::from("NEW_HUNK_A\n"); + for i in 1..=50 { + let line = match i { + 10 => "needleA".to_string(), + 35 => "needleB".to_string(), + _ => format!("ctx{i}"), + }; + committed.push_str(&line); + committed.push('\n'); + modified.push_str(&line); + modified.push('\n'); + } + committed.push_str("OLD_HUNK_B\n"); + modified.push_str("NEW_HUNK_B\n"); + + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", &committed, &modified) + .build() + .unwrap() + } + + #[test] + fn jump_to_search_match_accumulates_expansion_across_repeated_jumps_into_one_gap() { + let fixture = one_gap_with_two_needles_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + app.search_focus(); + for c in "needle".chars() { + app.search_insert_char(c); + } + app.search_accept(); + assert_eq!(app.search_matches().len(), 2, "both needles must be found"); + + let key = *app + .current_view_ref() + .unwrap() + .expansions + .keys() + .next() + .expect("jumping to needleA must have created a gap expansion entry"); + let after_first = app.current_view_ref().unwrap().expansions[&key]; + assert!( + !after_first.full, + "a bounded reveal must not flip the gap's `full` flag" + ); + assert!( + after_first.before > 0, + "needleA sits nearer the gap's leading edge, so the first jump must widen `before`" + ); + assert_eq!( + after_first.after, 0, + "the first jump must not have touched the trailing edge yet" + ); + + // needleB is still buried under the (now-narrower) gap — jumping to it must widen the + // TRAILING edge on top of the leading-edge widening the first jump already did, not + // discard it. + app.search_next(); + let after_second = app.current_view_ref().unwrap().expansions[&key]; + assert_eq!( + after_second.before, after_first.before, + "expand_gap accumulates: the second jump must not reset the first jump's `before` widening" + ); + assert!( + after_second.after > 0, + "needleB sits nearer the gap's trailing edge, so the second jump must widen `after`" + ); + assert!( + !after_second.full, + "two bounded reveals into a 50-row run must not have consumed the whole gap" + ); + } + #[test] fn search_next_and_prev_wrap_with_a_footer_notice() { // Two occurrences of the SAME needle on two different visible lines (both hunk change @@ -12677,6 +12812,111 @@ mod tests { assert!(app.search_matches().is_empty()); } + #[test] + fn toggle_layout_preserves_the_current_search_match() { + // Two matches so landing on the SECOND one (rather than the first, which a fresh + // recompute would also happen to pick) proves the index is actually carried across, + // not coincidentally re-derived. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "f.txt", + "alpha old\nctx\nbeta old\n", + "alpha needle\nctx\nbeta needle\n", + ) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + app.search_focus(); + for c in "needle".chars() { + app.search_insert_char(c); + } + app.search_accept(); + app.search_next(); + assert_eq!( + app.search_current_index(), + Some(1), + "precondition: parked on the second match" + ); + + app.toggle_layout(); + assert_eq!( + app.search_current_index(), + Some(1), + "a same-file layout flip must not lose the 'parked on match N' highlight — matches \ + address the layout-agnostic AlignedRow space, so it's still valid" + ); + assert_eq!( + app.search_matches().len(), + 2, + "the match list itself must still be intact after the flip" + ); + + // Flip back: still preserved, not a one-shot fluke of the first toggle. + app.toggle_layout(); + assert_eq!(app.search_current_index(), Some(1)); + } + + #[test] + fn search_current_still_resets_on_a_query_change_and_a_file_switch() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "a.txt", + "alpha old\nctx\nbeta old\n", + "alpha needle\nctx\nbeta needle\n", + ) + .unstaged_file("b.txt", "old\n", "needle\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + app.search_focus(); + for c in "needle".chars() { + app.search_insert_char(c); + } + app.search_accept(); + app.search_next(); + assert_eq!(app.search_current_index(), Some(1), "precondition"); + + // A file switch funnels through `reset_panes`, not the layout-toggle path — genuinely a + // different file's match list, so the old index has no claim to carry over. + app.next_file(); + assert_eq!( + app.search_current_index(), + None, + "switching files must still drop the parked-match highlight" + ); + + // Back on the first file: re-accepting is a query-change-shaped recompute (the plan's + // "changed query" case), which must also reset even though the match list ends up + // identical to before. + app.prev_file(); + app.search_focus(); + for c in "needle".chars() { + app.search_insert_char(c); + } + app.search_accept(); + app.search_next(); + assert_eq!( + app.search_current_index(), + Some(1), + "re-primed precondition" + ); + + app.search_backspace(); + app.search_insert_char('e'); // buffer back to "needle" — same effective query + assert_eq!( + app.search_current_index(), + None, + "a live prompt edit must reset the parked-match highlight even if the resulting \ + query is unchanged — CS3 only carries the index across a layout flip, nothing else" + ); + } + // ── 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 From 7e93ac758edb432db7a7ff3fa0f7a8cbe0311fb0 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 27 Jul 2026 15:23:00 -0400 Subject: [PATCH 196/203] feat(review): copy path:line to clipboard via OSC 52 --- git-workon-review/src/app.rs | 187 +++++++++++++++++++++++++++++ git-workon-review/src/clipboard.rs | 145 ++++++++++++++++++++++ git-workon-review/src/keymap.rs | 26 ++++ git-workon-review/src/lib.rs | 1 + git-workon-review/src/tui.rs | 3 + 5 files changed, 362 insertions(+) create mode 100644 git-workon-review/src/clipboard.rs diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index caa3c2a..87443a9 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -3850,6 +3850,64 @@ impl App { self.search_step(false); } + /// The pure half of `copy-path-line`: resolve the cursor's row to a `path:line` string, with + /// no I/O. Split out from [`Self::copy_path_line`] so line resolution is testable without a + /// controlling tty — the OSC 52 write below needs one (`/dev/tty` is `ENXIO` in a test + /// harness/CI), but this resolution never should have depended on one in the first place. + /// + /// The line is the NEW side's lineno, falling back to the OLD side on a pure-deletion row + /// that carries no new side (M11 handoff locked decision 2 — deliberately NOT "whichever + /// side the split cursor is on": that per-side state doesn't exist yet, see the open + /// question logged in `docs/rfc/workon-review.md`). `path` is repo-relative, the same string + /// already shown everywhere else in this UI (outline, footer) — never an absolute path. + /// + /// `Err` names the reason there's nothing to copy — a row with neither lineno (a gap row) or + /// no file/view at all — which [`Self::copy_path_line`] turns straight into a footer notice. + fn resolve_copy_path_line(&self) -> Result { + let path = self + .files() + .get(self.current) + .map(|f| f.path.clone()) + .ok_or("no file to copy")?; + let view = self.current_view_ref().ok_or("no line to copy")?; + let (old, new) = match self.layout { + Layout::Sbs => view + .display + .get(self.cursor) + .map(display_row_linenos) + .unwrap_or((None, None)), + Layout::Inline => view + .inline + .get(self.cursor) + .map(inline_row_linenos) + .unwrap_or((None, None)), + }; + let line = new.or(old).ok_or("no line to copy")?; + Ok(format!("{path}:{line}")) + } + + /// `y` (default binding `copy-path-line`): copy `path:line` for the cursor's row to the + /// system clipboard via OSC 52 ([`crate::clipboard::write_osc52`]). Resolution itself is + /// [`Self::resolve_copy_path_line`]; this wraps it with the I/O and the footer notice. + /// + /// The footer notice fires on both outcomes: success is worded "copied ... to clipboard", + /// deliberately not "clipboard updated" — OSC 52 is fire-and-forget (see the `clipboard` + /// module doc), so this can only claim the bytes reached the tty, never that the terminal + /// actually honored them. + pub fn copy_path_line(&mut self) { + let payload = match self.resolve_copy_path_line() { + Ok(payload) => payload, + Err(reason) => { + self.notify(reason, Severity::Error); + return; + } + }; + match crate::clipboard::write_osc52(&payload) { + Ok(()) => self.notify(format!("copied {payload} to clipboard"), Severity::Info), + Err(err) => self.notify(format!("clipboard write failed: {err}"), Severity::Error), + } + } + /// Park the cursor on [`Self::search_matches`]`[idx]`: auto-expand the gap it's hidden behind /// (if any — [`crate::align::gap_key_for_aligned_idx`] + [`FileView::expand_gap`], the /// existing CS8/CS9 machinery), then locate the row in the ACTIVE layout's own vector by the @@ -13639,4 +13697,133 @@ mod tests { pre-CS2 fallback for an unresolvable sync target" ); } + + // ── `copy-path-line` (M11) ────────────────────────────────────────────── + + /// A single pure deletion — `b` (old line 2) removed with nothing added in its place — so + /// the row it produces has an old lineno but NO new one, the fallback case + /// [`App::copy_path_line`]'s doc names. + fn pure_deletion_fixture() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "a\nb\nc\n", "a\nc\n") + .build() + .unwrap() + } + + /// A single pure addition — `b` (new line 2) inserted with nothing removed — the mirror of + /// [`pure_deletion_fixture`]: this row has a new lineno but no old one, the ordinary case + /// (new-side wins, no fallback needed). + fn pure_addition_fixture() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "a\nc\n", "a\nb\nc\n") + .build() + .unwrap() + } + + // These target `App::resolve_copy_path_line` directly rather than `App::copy_path_line` — + // resolution is pure, but `copy_path_line` itself writes to `/dev/tty` via + // `crate::clipboard::write_osc52`, which is `ENXIO` in a test harness/CI with no controlling + // tty. Asserting through the notice text would make line resolution depend on a real + // terminal for no reason; the byte-sequence tests in `clipboard.rs` cover the write side. + + #[test] + fn copy_path_line_uses_the_new_side_on_a_context_row_in_both_layouts() { + let fixture = pure_addition_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.cursor = 0; // the leading "a" context row: old 1, new 1 + + assert_eq!(app.resolve_copy_path_line(), Ok("f.txt:1".to_string())); + + app.toggle_layout(); + app.cursor = 0; + assert_eq!(app.resolve_copy_path_line(), Ok("f.txt:1".to_string())); + } + + #[test] + fn copy_path_line_uses_the_new_side_on_a_pure_addition_row_in_both_layouts() { + let fixture = pure_addition_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let row = app + .current_view_ref() + .unwrap() + .display + .iter() + .position(|r| matches!(r, DisplayRow::Row(row) if row.new == Row::Line(2))) + .expect("the inserted 'b' has its own SBS row at new line 2"); + app.cursor = row; + + assert_eq!(app.resolve_copy_path_line(), Ok("f.txt:2".to_string())); + + app.toggle_layout(); + let inline_row = app + .current_view_ref() + .unwrap() + .inline + .iter() + .position(|r| matches!(r, InlineRow::Add { new: 2, .. })) + .expect("the inserted 'b' has its own inline Add row"); + app.cursor = inline_row; + assert_eq!(app.resolve_copy_path_line(), Ok("f.txt:2".to_string())); + } + + #[test] + fn copy_path_line_falls_back_to_the_old_lineno_on_a_pure_deletion_row_in_both_layouts() { + let fixture = pure_deletion_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let row = app + .current_view_ref() + .unwrap() + .display + .iter() + .position(|r| matches!(r, DisplayRow::Row(row) if row.old == Row::Line(2))) + .expect("the deleted 'b' has its own SBS row at old line 2"); + app.cursor = row; + + assert_eq!( + app.resolve_copy_path_line(), + Ok("f.txt:2".to_string()), + "no new side on a pure deletion: falls back to the old lineno" + ); + + app.toggle_layout(); + let inline_row = app + .current_view_ref() + .unwrap() + .inline + .iter() + .position(|r| matches!(r, InlineRow::Del { old: 2, .. })) + .expect("the deleted 'b' has its own inline Del row"); + app.cursor = inline_row; + assert_eq!(app.resolve_copy_path_line(), Ok("f.txt:2".to_string())); + } + + #[test] + fn copy_path_line_resolver_errs_instead_of_returning_garbage_on_a_gap_row_in_both_layouts() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.cursor = only_gap_row(&app); + + assert_eq!( + app.resolve_copy_path_line(), + Err("no line to copy"), + "a gap row carries neither an old nor a new lineno" + ); + + app.toggle_layout(); + let inline_gap = app + .current_view_ref() + .unwrap() + .inline + .iter() + .position(|r| matches!(r, InlineRow::Gap { .. })) + .expect("the same wide context run collapses to an inline Gap row too"); + app.cursor = inline_gap; + assert_eq!(app.resolve_copy_path_line(), Err("no line to copy")); + } } diff --git a/git-workon-review/src/clipboard.rs b/git-workon-review/src/clipboard.rs new file mode 100644 index 0000000..ced67fd --- /dev/null +++ b/git-workon-review/src/clipboard.rs @@ -0,0 +1,145 @@ +//! OSC 52 clipboard writes (M11, `copy-path-line`). +//! +//! The only clipboard mechanism this crate has: an OSC 52 "set clipboard" escape sequence +//! written to the controlling tty. No `arboard`-style dependency — the M11 handoff's locked +//! decision 1 rejected both a pure-`arboard` approach (dependency tree, dead over SSH) and an +//! `arboard`-with-OSC-52-fallback hybrid (two code paths for one keybinding, which the +//! CLAUDE.md "simplicity wins" rule doesn't allow without a concrete reason). `base64_encode` +//! below hand-rolls the small amount of base64 OSC 52 needs rather than pulling in a crate for +//! it. +//! +//! ## Fire-and-forget, by protocol +//! +//! OSC 52 has no reply: a terminal that honors it just updates its clipboard silently, and one +//! that doesn't either ignores the sequence or (rarely) echoes stray bytes if some intermediate +//! layer mishandles it — either way, nothing comes back on the wire to tell the caller which +//! happened. [`write_osc52`] returning `Ok(())` therefore means only "the bytes reached +//! `/dev/tty`", never "the clipboard actually changed". [`crate::app::App::copy_path_line`]'s +//! footer notice is worded to match: "copied ... to clipboard", never "clipboard updated" — +//! the latter phrasing is a claim a silent failure could falsify. +//! +//! ## Known gaps (deliberately deferred) +//! +//! - **Terminal.app does not implement OSC 52 at all.** The write reaches the tty and is +//! silently swallowed; there is no way to detect this from here. +//! - **tmux only forwards OSC 52 to the outer terminal when `set -g set-clipboard on`** is set +//! in `tmux.conf`. Without it, tmux eats the sequence itself. +//! +//! Neither is fixable from this call site alone, and the target environment (Kitty, no tmux, no +//! SSH) doesn't hit either — so both are logged here as the discoverable next step (a fallback +//! mechanism behind [`write_osc52`]) rather than worked around now. +//! +//! ## Why this write skips `terminal_query.rs`'s tty discipline +//! +//! [`crate::terminal_query`]'s module doc documents hard-won rules for talking to `/dev/tty`: +//! non-blocking reads, a hard deadline, always-restore `termios`. Those rules exist because that +//! probe READS a reply under a raw-mode tty it does not yet own (it runs before `tui::run` +//! installs raw mode/alt screen). This call is a pure write, with no reply to wait for, running +//! AFTER the TUI has already put the tty in raw mode and owns it for the session — there is +//! nothing to save/restore and no deadline to bound, so none of that machinery applies. The one +//! rule that does carry over: never leave the tty in a different state than found. A write of a +//! newline-free escape sequence can't perturb canonical/raw mode or echo (those govern how input +//! is read back, not how output is written), so simply opening, writing, and closing `/dev/tty` +//! satisfies that rule for free. + +use std::io; + +const BASE64_ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Base64-encode `data` with the standard alphabet and `=` padding (RFC 4648 section 4) — what +/// OSC 52's payload requires. Hand-rolled rather than a dependency; see the module doc. +pub(crate) fn base64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b0 = chunk[0]; + let b1 = chunk.get(1).copied().unwrap_or(0); + let b2 = chunk.get(2).copied().unwrap_or(0); + let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2); + out.push(BASE64_ALPHABET[((n >> 18) & 0x3f) as usize] as char); + out.push(BASE64_ALPHABET[((n >> 12) & 0x3f) as usize] as char); + out.push(if chunk.len() > 1 { + BASE64_ALPHABET[((n >> 6) & 0x3f) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + BASE64_ALPHABET[(n & 0x3f) as usize] as char + } else { + '=' + }); + } + out +} + +/// Wrap `payload` as an OSC 52 "set clipboard" escape sequence: `ESC ] 52 ; c ; ESC \`. +/// `c` selects the system clipboard (as opposed to OSC 52's `p`/`q` primary/secondary selection +/// targets, which this crate never uses). Returns the raw bytes ready to write to a tty. +pub(crate) fn osc52_sequence(payload: &str) -> Vec { + let b64 = base64_encode(payload.as_bytes()); + let mut seq = Vec::with_capacity(b64.len() + 8); + seq.push(0x1b); // ESC + seq.extend_from_slice(b"]52;c;"); + seq.extend_from_slice(b64.as_bytes()); + seq.push(0x1b); // ST (String Terminator) part 1 + seq.push(b'\\'); // ST part 2 + seq +} + +/// Write an OSC 52 "set clipboard" sequence for `payload` to the controlling tty. See the module +/// doc for why this is fire-and-forget and why it doesn't touch `termios`. The `Err` case this +/// CAN detect and surface is real: `/dev/tty` failing to open (no controlling terminal at all, +/// e.g. output piped somewhere unusual) or the write itself failing — as opposed to the terminal +/// silently declining to honor a sequence that reached it, which is undetectable by design. +#[cfg(unix)] +pub(crate) fn write_osc52(payload: &str) -> io::Result<()> { + use std::io::Write; + let mut tty = std::fs::File::options().write(true).open("/dev/tty")?; + tty.write_all(&osc52_sequence(payload))?; + tty.flush() +} + +#[cfg(not(unix))] +pub(crate) fn write_osc52(_payload: &str) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "OSC 52 clipboard write is only implemented on unix", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_encode_pads_per_rfc_4648() { + // The three padding cases: 0, 1, 2 bytes of trailing padding. + assert_eq!(base64_encode(b"foo"), "Zm9v"); // 3 bytes, no padding + assert_eq!(base64_encode(b"fo"), "Zm8="); // 2 bytes, one pad + assert_eq!(base64_encode(b"f"), "Zg=="); // 1 byte, two pads + assert_eq!(base64_encode(b""), ""); + } + + #[test] + fn base64_encode_matches_a_realistic_path_line_payload() { + assert_eq!(base64_encode(b"src/app.rs:42"), "c3JjL2FwcC5yczo0Mg=="); + } + + /// Assert the exact byte sequence, not terminal behavior (per the M11 handoff) — this is the + /// wire format every OSC-52-aware terminal parses, so any drift here is a real regression. + #[test] + fn osc52_sequence_wraps_base64_payload_in_esc_bracket_st() { + let seq = osc52_sequence("foo"); + let mut expected = vec![0x1b]; + expected.extend_from_slice(b"]52;c;Zm9v"); + expected.push(0x1b); + expected.push(b'\\'); + assert_eq!(seq, expected); + } + + #[test] + fn osc52_sequence_encodes_a_path_line_payload() { + let seq = osc52_sequence("src/app.rs:42"); + assert_eq!(seq, b"\x1b]52;c;c3JjL2FwcC5yczo0Mg==\x1b\\".to_vec()); + } +} diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 46c28e0..f0c9ca2 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -75,6 +75,7 @@ pub enum Command { Search, SearchNext, SearchPrev, + CopyPathLine, // Diff view. FocusOutline, // Outline view. @@ -371,6 +372,13 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "N", description: "Previous search match (or previous hunk, when no search is active)", }, + Registered { + command: Command::CopyPathLine, + view: View::Diff, + name: "copy-path-line", + default_keys: "y", + description: "Copy path:line for the cursor row to the clipboard", + }, // ── Outline view ───────────────────────────────────────────────────────── Registered { command: Command::OutlineDown, @@ -1375,6 +1383,24 @@ mod tests { ); } + /// M11 (`copy-path-line`): `y` was free in both `View::Global` and `View::Diff` (unlike `p`, + /// which `[h`'s extra default and the outline's `prev-changeset` already claim), so no + /// existing binding needed to move to make room for it — unlike `cycle-zoom`'s `z` -> `Z` + /// rebind above. Pins the resolved default clash-free the same way those tests do. + #[test] + fn y_dispatches_copy_path_line_with_no_collisions() { + let km = Keymap::defaults(); + assert!( + km.warnings().is_empty(), + "copy-path-line's default `y` must not collide with anything: {:?}", + km.warnings() + ); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('y'))]), + Dispatch::Command(Command::CopyPathLine) + ); + } + #[test] fn a_config_rebind_overrides_the_default() { let km = Keymap::from_bindings(&[RawBinding { diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index efe3cab..ee6c970 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -17,6 +17,7 @@ pub mod align; pub mod app; pub mod apply; pub mod attribute; +pub mod clipboard; pub mod config; pub mod error; pub mod file_ops; diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index feb671d..b673cdc 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -473,6 +473,7 @@ enum Action { SearchFocus, SearchNext, SearchPrev, + CopyPathLine, None, } @@ -511,6 +512,7 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::Search => Action::SearchFocus, Command::SearchNext => Action::SearchNext, Command::SearchPrev => Action::SearchPrev, + Command::CopyPathLine => Action::CopyPathLine, Command::NextFile => Action::NextFile, Command::PrevFile => Action::PrevFile, Command::NextHunk => Action::NextHunk, @@ -686,6 +688,7 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::SearchFocus => app.search_focus(), Action::SearchNext => app.search_next(), Action::SearchPrev => app.search_prev(), + Action::CopyPathLine => app.copy_path_line(), Action::None => {} } false From 46c3e5b82b1bae6854ebc9b77aaaa81a39def9c6 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 27 Jul 2026 16:44:04 -0400 Subject: [PATCH 197/203] fix(review): tolerate and self-heal a stale-diff align race --- git-workon-review/src/align.rs | 42 +++++++++++---- git-workon-review/src/app.rs | 93 +++++++++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 12 deletions(-) diff --git a/git-workon-review/src/align.rs b/git-workon-review/src/align.rs index 436993c..3aca225 100644 --- a/git-workon-review/src/align.rs +++ b/git-workon-review/src/align.rs @@ -75,6 +75,13 @@ impl AlignedRow { pub struct Aligned { pub rows: Vec, + /// Whether [`align_file`] had to clamp a hunk-gap or trailing-tail span whose old/new + /// lengths disagreed — see the clamps below for why this is a real, reachable runtime state + /// (stale diff geometry against a freshly-read blob) rather than a bug. `false` for the + /// common case where `hunks`/`old_line_count`/`new_line_count` were all derived from the + /// same file revision, which is every path except a load racing a concurrent workdir write + /// (see [`crate::app::FileView::load`]). + pub mismatched: bool, } fn gap_end(start: usize, count: usize) -> usize { @@ -119,6 +126,9 @@ pub fn align_file(hunks: &[Hunk], old_line_count: usize, new_line_count: usize) let mut rows = Vec::new(); let mut old_pos = 0usize; // count of old lines already emitted let mut new_pos = 0usize; + // Set when a gap or the tail below has to clamp instead of pairing 1:1 — see `Aligned:: + // mismatched`'s doc comment for why this is reachable at runtime rather than a bug. + let mut mismatched = false; for hunk in hunks { let old_start = hunk.old_start as usize; @@ -130,10 +140,18 @@ pub fn align_file(hunks: &[Hunk], old_line_count: usize, new_line_count: usize) let new_ge = gap_end(new_start, new_count); let old_gap = old_ge.saturating_sub(old_pos); let new_gap = new_ge.saturating_sub(new_pos); - debug_assert_eq!( - old_gap, new_gap, - "context gap between hunks must be equal length on both sides" - ); + // `old_gap`/`new_gap` disagreeing means `hunks` itself carries internally inconsistent + // geometry — every hunk in a single valid diff is self-consistent with its neighbors (all + // positions relative to the same two blobs), so this branch shouldn't fire for hunks this + // module actually receives today. But `align_file` has no way to verify a `hunks` slice + // it's handed is well-formed, and the tail clamp below proves a geometry assumption CAN + // silently break for a reason outside this function's control (a load racing a concurrent + // workdir write — see `Aligned::mismatched`'s doc comment). Treating this the same way — + // clamp and flag, don't assert — costs nothing and keeps both clamps symmetric rather + // than leaving one crash-on-mismatch path alive for a future caller to rediscover. + if old_gap != new_gap { + mismatched = true; + } let gap = old_gap.min(new_gap); for i in 0..gap { rows.push(AlignedRow { @@ -173,13 +191,17 @@ pub fn align_file(hunks: &[Hunk], old_line_count: usize, new_line_count: usize) new_pos = new_start + new_count.saturating_sub(1); } - // Tail gap after the last hunk (or the whole file, if there are no hunks). + // Tail gap after the last hunk (or the whole file, if there are no hunks). This IS the + // empirically-confirmed mismatch (unlike the inter-hunk gap above): `old_line_count`/ + // `new_line_count` are read from the full old/new text at LOAD time (a live workdir read for + // the new side, per `crate::app::FileView::load`), while `old_pos`/`new_pos` derive from + // `hunks`, acquired earlier — a concurrent write between the two makes the tail lengths + // disagree. Clamp to the shorter side and flag it rather than asserting. let old_tail = old_line_count.saturating_sub(old_pos); let new_tail = new_line_count.saturating_sub(new_pos); - debug_assert_eq!( - old_tail, new_tail, - "trailing context after the last hunk must be equal length on both sides" - ); + if old_tail != new_tail { + mismatched = true; + } let tail = old_tail.min(new_tail); for i in 0..tail { rows.push(AlignedRow { @@ -190,7 +212,7 @@ pub fn align_file(hunks: &[Hunk], old_line_count: usize, new_line_count: usize) }); } - Aligned { rows } + Aligned { rows, mismatched } } /// A row of the gap-collapsed display, layered over [`AlignedRow`]s. diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 87443a9..b4dbdba 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -118,6 +118,12 @@ pub struct FileView { display_hunk: Vec>, /// Inline-coordinate analog of [`Self::display_hunk`], indexed against [`Self::inline`]. inline_hunk: Vec>, + /// Carried straight from [`crate::align::Aligned::mismatched`] — this load's hunk geometry + /// disagreed with the old/new line counts it was aligned against (a concurrent workdir write + /// between diff acquisition and this load's blob read). `ensure_role_loaded` reads this once, + /// right after building the view, to decide whether to trigger a one-shot re-diff; the field + /// itself is inert afterward (nothing re-checks it later). + pub(crate) geometry_mismatch: bool, } impl FileView { @@ -162,12 +168,12 @@ impl FileView { let old_lines: Vec = old_text.lines().map(str::to_string).collect(); let new_lines: Vec = new_text.lines().map(str::to_string).collect(); - let aligned = align_file(&file.hunks, old_lines.len(), new_lines.len()).rows; + let aligned = align_file(&file.hunks, old_lines.len(), new_lines.len()); let old_hl = ts.highlight_file(old_source_path, &old_text); let new_hl = ts.highlight_file(&file.path, &new_text); let mut view = Self { - aligned, + aligned: aligned.rows, expansions: HashMap::new(), hunks: file.hunks.clone(), old_text, @@ -184,6 +190,7 @@ impl FileView { inline_word_spans: HashMap::new(), display_hunk: Vec::new(), inline_hunk: Vec::new(), + geometry_mismatch: aligned.mismatched, }; view.rebuild_rows(); view @@ -1484,6 +1491,13 @@ pub struct App { /// after a trigger with no "cursor is parked on match N" claim to make (see /// [`Self::recompute_search`] vs [`Self::recompute_search_keep_current`]). search_current: Option, + /// Set for the DURATION of a [`Self::coordinated_refresh`] triggered by + /// [`Self::handle_geometry_mismatch`] — guards against a refresh loop when a file is being + /// written to continuously: while `true`, a mismatch detected by a load nested inside that + /// refresh (its own `open_current` reloading the same file) is tolerated with the clamp + /// instead of triggering ANOTHER refresh. Always `false` outside that call; never persists + /// across separate load attempts, so the next one gets its own single retry. + refreshing_for_geometry_mismatch: bool, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -1674,6 +1688,7 @@ impl App { search_focused: false, search_matches: Vec::new(), search_current: None, + refreshing_for_geometry_mismatch: false, }; // Position the outline cursor on the changeset/file the lib marked `current` (the same // row `sync_outline_to_current` would reposition to after any diff-initiated nav) rather @@ -2170,6 +2185,12 @@ impl App { else { return; }; + if self.handle_geometry_mismatch(&view) { + // The nested refresh's own `open_current` already reloaded (and cached) this + // file/role through this same chokepoint — see `handle_geometry_mismatch`'s doc + // comment. Nothing left for this call to do. + return; + } self.views_for_mut(role)[idx] = Some(view); return; } @@ -2182,9 +2203,47 @@ impl App { let Some(view) = build_combined_view(&self.repo, &mut self.highlighter, span, &file) else { return; }; + if self.handle_geometry_mismatch(&view) { + return; + } self.cur_mut().views_combined[idx] = Some(view); } + /// A just-built `view` whose [`FileView::geometry_mismatch`] is set means its hunks were + /// diffed against a DIFFERENT revision than the one [`FileView::load`] just read blobs from + /// (a concurrent workdir write racing the load — see [`crate::align::Aligned::mismatched`]). + /// Part 1's clamp already keeps that survivable, but a silently clamped tail is still wrong + /// content on screen, so this drives [`Self::coordinated_refresh`] to re-acquire the diff + /// against the file's CURRENT state instead of just rendering the clamp. + /// + /// Returns `true` when it triggered a refresh — the caller must NOT cache `view` in that case; + /// [`Self::coordinated_refresh`]'s own [`Self::refresh`] ends in [`Self::open_current`], which + /// re-enters [`Self::ensure_role_loaded`] for the same file and caches whatever THAT retry + /// produces. Returns `false` (view unaffected) when there's no mismatch, or when this IS that + /// retry — [`Self::refreshing_for_geometry_mismatch`] guards against a refresh loop for a file + /// under continuous writes: at most one refresh per load attempt. A mismatch that survives the + /// retry is accepted via the clamp, with a footer notice telling the user their diff may be + /// misaligned, rather than refreshing forever. + fn handle_geometry_mismatch(&mut self, view: &FileView) -> bool { + if !view.geometry_mismatch { + return false; + } + if self.refreshing_for_geometry_mismatch { + // No key hint here (unlike `notify_combined_refusal`'s zoom-key label): `refresh` is + // a remappable binding this call site has no seated label for, and inventing one + // just for this message isn't worth a second `zoom_key_label`-style field. + self.notify( + "file changed on disk while loading — diff may be misaligned; refresh to fix", + Severity::Info, + ); + return false; + } + self.refreshing_for_geometry_mismatch = true; + self.coordinated_refresh(); + self.refreshing_for_geometry_mismatch = false; + true + } + pub fn current_view(&mut self) -> Option<&mut FileView> { let role = self.focused_role_for(self.current); self.ensure_role_loaded(self.current, role); @@ -7476,6 +7535,36 @@ mod tests { ); } + // ---- stale-diff alignment crash: workdir races diff acquisition ---------------------- + + /// The confirmed repro (2026-07-27 handoff): `file.hunks` are diffed against the workdir + /// state as it stood at diff-acquisition time, but `FileView::load`'s new-side text for + /// `Role::Unstaged`/`Combined` is a LIVE workdir read (see its role table) — if the file grows + /// on disk in between (an editor or agent writing to it while the TUI sits idle), the hunk + /// geometry and the freshly-read line count describe different revisions of the same file. + /// Before the fix this panicked the `debug_assert_eq!` in `align.rs`'s tail-gap clamp + /// (`left: 0, right: 3`, `trailing context after the last hunk must be equal length on both + /// sides`). Part 1 makes `align_file` tolerant instead of asserting; Part 2 detects the + /// mismatch and re-diffs once to correct it — this test only pins that the load survives. + /// + /// Note the asymmetry the handoff calls out: the file GROWING reproduces this; the same + /// fixture with the workdir SHRUNK does not (the mismatch only escapes the pre-fix `.min()` + /// clamp in one direction) — this test deliberately only covers the growing case. + #[test] + fn workdir_growing_after_diff_acquisition_does_not_crash_the_load() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "a\nb\nc\nd\ne\n", "a\nB\nc\nd\ne\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + // Diffs are already acquired. Now the file grows on disk, as it would if an editor or an + // agent wrote to it while the TUI sat idle. + let workdir = fixture.repo().unwrap().workdir().unwrap().to_path_buf(); + std::fs::write(workdir.join("f.txt"), "a\nB\nc\nd\ne\nf\ng\nh\n").unwrap(); + app.open_current(); + } + // ---- ADR-031 refresh: span-keyed reuse, uncommitted always sync, async waves ---------- /// Build a two-commit chain (`root` then `head`) on the fixture's default branch and return From 5ea0a110afc99d8b8c17a2f2a3c488f0e2dbbb4c Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 27 Jul 2026 16:46:15 -0400 Subject: [PATCH 198/203] docs(review): log cursor-side, zoom-cycle, and deferred-load gaps --- docs/rfc/workon-review.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 4a3781d..162b689 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -148,8 +148,14 @@ The remaining roadmap is resequenced around the tool being **the author's own ev - **M10 — editor jump / edit flow** *(was M8)*. Jump from a diff line to `file:line` — embedded `nvim --server $NVIM --remote + `, standalone `$EDITOR + ` (detect via `$NVIM`); file watcher refreshes the diff on external save — port the prototype's debounced repo-root watcher (`FocusGained` fallback, viewport-preserving refresh, selection clamp; the Neovim mechanism doesn't translate, the behavior does). **Graduated from agent-loop into daily-core:** under (B) you review and want to *fix* the thing. Acceptance: jump opens the right file+line; saving refreshes without losing viewport. + **Carried-forward gap from the 2026-07-27 stale-diff fix.** A load whose hunks were diffed against one revision while its blob read saw a later one now clamps instead of crashing, and `ensure_role_loaded` self-heals it with a one-shot re-diff. But that trigger is wired only into the **synchronous eager** load path. The ADR-031 deferred loader-thread path (`build_file_views`, off-thread with no `&mut App`) and its landing site `apply_file_ready` were deliberately left unwired — signalling a mismatch through `LoadedViews`/`FileLoadSpec` would have been a second plumbing path. Consequence: a file opened via the deferred path during a continuous-write race shows clamped-but-uncorrected content until the next tick or a manual refresh. Harmless while writes are incidental; **the watcher makes them routine**, so wire the deferred path as part of this milestone. + - **M11 — polish.** Worktree-switch hub in the TUI (surface git-workon's create/find/prune/switch so the TUI is a hub — vs staying review-only; decide during design) + in-diff navigation (fuzzy jump-to-file, search-in-diff, context expand/collapse, ignore-whitespace toggle, copy `path:line`). Acceptance: per the design cut. + **Open question — should the SBS cursor have a side?** (raised 2026-07-27 during the copy-`path:line` design.) Today `cursor` is a single row index spanning both halves of the side-by-side view; `SplitPane` is the *staged/unstaged* split, not old/new, and no keybinding or render signal distinguishes the two halves. Dogfooding confirms the halves are visually indistinguishable because there is nothing to distinguish — the state doesn't exist. Adding it means: a side dimension on the cursor, a key to move between halves, extending the `cursor_unfocused_bg` / `pane_header_focused_fg` focus signaling (currently wired to the staged/unstaged split), and a per-consumer decision for staging, line selection, search jump, and copy about whether they care about the side. Plausibly the right long-term model — it would also sharpen ignore-whitespace and line-ops — but it is a design conversation and its own slice, not a sub-decision of another feature. Copy `path:line` deliberately sidesteps it by always using the new side (old on pure-deletion rows). + + **Open question — does the user-facing zoom cycle earn its keep?** (raised 2026-07-27.) Distinguish two things currently sharing the name: `EffectiveZoom` (the derived `Single(Role)`/`Split` resolution) is load-bearing for the staged/unstaged split view itself and is not in question; the user-facing `Zoom` cycle (`Z`, the 4-variant enum `Split → Combined → Unstaged → Staged`, the config setting) is. Dogfooding reports little use for it — structurally so, since `effective_zoom` downgrades `Split` to a single pane unless a file has BOTH sub-diffs, making `Split`/`Unstaged`/`Combined` render identically in a purely-unstaged worktree. The feature is dormant rather than useless: it activates precisely in the partially-staged workflow that **M8 commit operations** makes central (`Staged`-solo to verify what will land, `Combined` to see the file whole while deciding what to stage next). Decision deferred until M8 ships and supplies real usage evidence; the live options are to collapse the cycle to two states (auto ↔ combined) or leave it as-is. Note a known residual meanwhile: a zoom change resets the current search-match highlight even when the rendered content is identical (the layout-toggle case was fixed 2026-07-27; the zoom case was deliberately left, since preserving across genuinely different roles would be wrong and the right fix depends on this decision). + - **M12 — conflict resolution** *(stretch)*. Resolve merge/rebase conflicts in the SBS view. Large surface; may not make v1. - **M13 — agent loop** *(the eventual north star; was M7 comments + M9 MCP)*. On-disk comment store keyed to `(changeset_id, path, side, lnum)` with a rebase-survival anchoring strategy + TUI comment UX (create/view/resolve, store-watch refresh), and a **unified `git workon mcp`** stdio server bridging git-workon-lib worktree tools (`agent-integration.md` Model C) *and* the comment store. **Open forks (unchanged, resolve at design time):** comment-store home — a lib both the review crate and `git-workon` depend on, since `git workon mcp` is a second consumer (reopens the "no separate core crate" decision); the anchoring strategy; MCP crate/transport (`rmcp` vs hand-rolled JSON-RPC-over-stdio). Deferred behind the daily-driver work. From af30ca62b57193648ec55e8dfe51010461172ffb Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 27 Jul 2026 18:45:43 -0400 Subject: [PATCH 199/203] docs(review): log combined-view exemptions as zoom evidence --- docs/rfc/workon-review.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 162b689..84a75c5 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -156,6 +156,8 @@ The remaining roadmap is resequenced around the tool being **the author's own ev **Open question — does the user-facing zoom cycle earn its keep?** (raised 2026-07-27.) Distinguish two things currently sharing the name: `EffectiveZoom` (the derived `Single(Role)`/`Split` resolution) is load-bearing for the staged/unstaged split view itself and is not in question; the user-facing `Zoom` cycle (`Z`, the 4-variant enum `Split → Combined → Unstaged → Staged`, the config setting) is. Dogfooding reports little use for it — structurally so, since `effective_zoom` downgrades `Split` to a single pane unless a file has BOTH sub-diffs, making `Split`/`Unstaged`/`Combined` render identically in a purely-unstaged worktree. The feature is dormant rather than useless: it activates precisely in the partially-staged workflow that **M8 commit operations** makes central (`Staged`-solo to verify what will land, `Combined` to see the file whole while deciding what to stage next). Decision deferred until M8 ships and supplies real usage evidence; the live options are to collapse the cycle to two states (auto ↔ combined) or leave it as-is. Note a known residual meanwhile: a zoom change resets the current search-match highlight even when the rendered content is identical (the layout-toggle case was fixed 2026-07-27; the zoom case was deliberately left, since preserving across genuinely different roles would be wrong and the right fix depends on this decision). + **Data point — `Combined` accumulates exemptions** (added 2026-07-27.) Every verb that needs to know WHICH side it acts on has had to carve `Combined` out by hand: staging (`stage_hunk`/`stage_file`/`unstage`/`discard`, five `notify_combined_refusal` call sites) and line selection (`start_selection`) all refuse outright, because `staging_role()` returns `None` there. The pattern is that `Combined` is a read-only view wearing the same clothes as two editable ones, so each new verb pays a "does this even mean anything here?" tax and the user pays it again as a refusal notice. Note the counter-evidence, though: `copy-lines` (M11) needed NO exemption — its new-side-with-old-fallback rule is total, so `Combined` yanks fine. So the tax lands on MUTATING verbs specifically, not on all of them, which suggests the real question is whether `Combined` should be a zoom state at all versus a distinct read-only mode. Feeds the zoom-cycle decision above; do not resolve independently of it. + - **M12 — conflict resolution** *(stretch)*. Resolve merge/rebase conflicts in the SBS view. Large surface; may not make v1. - **M13 — agent loop** *(the eventual north star; was M7 comments + M9 MCP)*. On-disk comment store keyed to `(changeset_id, path, side, lnum)` with a rebase-survival anchoring strategy + TUI comment UX (create/view/resolve, store-watch refresh), and a **unified `git workon mcp`** stdio server bridging git-workon-lib worktree tools (`agent-integration.md` Model C) *and* the comment store. **Open forks (unchanged, resolve at design time):** comment-store home — a lib both the review crate and `git-workon` depend on, since `git workon mcp` is a second consumer (reopens the "no separate core crate" decision); the anchoring strategy; MCP crate/transport (`rmcp` vs hand-rolled JSON-RPC-over-stdio). Deferred behind the daily-driver work. From 5d0693f29102b1d83bb1b34d953f99ce69c50fe7 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 27 Jul 2026 18:52:34 -0400 Subject: [PATCH 200/203] feat(review): split yank into range-aware copy-lines and copy-location --- git-workon-review/src/app.rs | 367 ++++++++++++++++++++++++----- git-workon-review/src/clipboard.rs | 7 +- git-workon-review/src/keymap.rs | 36 ++- git-workon-review/src/tui.rs | 9 +- 4 files changed, 343 insertions(+), 76 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index b4dbdba..4f32223 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -3909,62 +3909,161 @@ impl App { self.search_step(false); } - /// The pure half of `copy-path-line`: resolve the cursor's row to a `path:line` string, with - /// no I/O. Split out from [`Self::copy_path_line`] so line resolution is testable without a - /// controlling tty — the OSC 52 write below needs one (`/dev/tty` is `ENXIO` in a test - /// harness/CI), but this resolution never should have depended on one in the first place. + /// Which side (old or new) each row the active yank range covers resolves to — the rule M11's + /// yank-split handoff locks as decision 4, shared by [`Self::resolve_copy_lines`] and + /// [`Self::resolve_copy_location`] so the two verbs cannot drift on side selection or gap + /// handling. Walks [`Self::selection_range`] (or the bare cursor row when no selection is + /// active) in the FOCUSED pane's ACTIVE layout coordinate space — the same space + /// [`Self::selection_range`] itself is already in, so no translation happens here. /// - /// The line is the NEW side's lineno, falling back to the OLD side on a pure-deletion row - /// that carries no new side (M11 handoff locked decision 2 — deliberately NOT "whichever - /// side the split cursor is on": that per-side state doesn't exist yet, see the open - /// question logged in `docs/rfc/workon-review.md`). `path` is repo-relative, the same string - /// already shown everywhere else in this UI (outline, footer) — never an absolute path. + /// - **SBS**: the NEW side's lineno, falling back to the OLD side on a pure-deletion row that + /// carries no new side (the same rule the old single-row `copy-path-line` resolver used). + /// `DisplayRow::Gap` rows are skipped, never emitted. + /// - **Inline**: `Del` -> old lineno, `Add` -> new lineno, `Context` -> new lineno — mirroring + /// [`Self::selection_line_ops`]'s per-side-precise handling (locked decision #8). + /// `InlineRow::Gap` rows are skipped. /// - /// `Err` names the reason there's nothing to copy — a row with neither lineno (a gap row) or - /// no file/view at all — which [`Self::copy_path_line`] turns straight into a footer notice. - fn resolve_copy_path_line(&self) -> Result { + /// Each entry is `(is_new_side, lineno)`, one per non-gap row in range order — the order the + /// caller needs both to pick text (per side) and to collapse a range to its first/last + /// lineno (decision 6). `Err("no line to copy")` when nothing in range yields a lineno at + /// all: no file/view loaded, or the whole range is gap rows (decision 5 — a gap is hidden + /// content, skipping it silently is correct, but an ALL-gap range has nothing left to copy). + fn resolve_yank_rows(&self) -> Result, &'static str> { + let view = self.current_view_ref().ok_or("no line to copy")?; + let (lo, hi) = self.selection_range().unwrap_or((self.cursor, self.cursor)); + let mut rows = Vec::new(); + match self.layout { + Layout::Sbs => { + for r in lo..=hi { + let Some(row) = view.display.get(r) else { + continue; + }; + let (old, new) = display_row_linenos(row); + if let Some(n) = new { + rows.push((true, n)); + } else if let Some(n) = old { + rows.push((false, n)); + } + } + } + Layout::Inline => { + for r in lo..=hi { + match view.inline.get(r) { + Some(InlineRow::Del { old, .. }) => rows.push((false, *old)), + Some(InlineRow::Add { new, .. }) => rows.push((true, *new)), + Some(InlineRow::Context { new, .. }) => rows.push((true, *new)), + Some(InlineRow::Gap { .. }) | None => {} + } + } + } + } + if rows.is_empty() { + Err("no line to copy") + } else { + Ok(rows) + } + } + + /// The pure half of `copy-lines` (`y`): resolve the active yank range (decision 4's side + /// rules via [`Self::resolve_yank_rows`]) to the selected rows' raw TEXT, no I/O. One line + /// per resolved row, newline-joined, in range order — no `+`/`-` markers, no line numbers, no + /// path header (locked decision 3: the dominant use is pasting into a chat or a buffer, and + /// markers make the result non-compiling). Text comes straight from [`FileView::old_lines`]/ + /// [`FileView::new_lines`] indexed by the row's resolved lineno minus 1 — same-module private + /// fields, no accessor needed. + fn resolve_copy_lines(&self) -> Result { + let view = self.current_view_ref().ok_or("no line to copy")?; + let rows = self.resolve_yank_rows()?; + let lines: Vec<&str> = rows + .iter() + .map(|&(is_new, lineno)| { + let buf = if is_new { + &view.new_lines + } else { + &view.old_lines + }; + buf.get(lineno - 1).map(String::as_str).unwrap_or("") + }) + .collect(); + Ok(lines.join("\n")) + } + + /// The pure half of `copy-location` (`Y`): today's single-row `resolve_copy_path_line` + /// widened to a range. `path` is repo-relative, the same string already shown everywhere else + /// in this UI (outline, footer) — never an absolute path. + /// + /// `lo`/`hi` are the resolved rows' FIRST and LAST entries from [`Self::resolve_yank_rows`] + /// (decision 6) — the range's endpoints in resolved-lineno space, not raw row indices (a row + /// index is meaningless outside the TUI) and not a min/max sweep (a range's endpoints, per the + /// plan, not its extremes). A single-row selection, or no selection, collapses to today's + /// `path:12` form byte-for-byte; a genuine multi-row range emits `path:lo-hi`, not GitHub's + /// `path#L12-L18`. + fn resolve_copy_location(&self) -> Result { let path = self .files() .get(self.current) .map(|f| f.path.clone()) .ok_or("no file to copy")?; - let view = self.current_view_ref().ok_or("no line to copy")?; - let (old, new) = match self.layout { - Layout::Sbs => view - .display - .get(self.cursor) - .map(display_row_linenos) - .unwrap_or((None, None)), - Layout::Inline => view - .inline - .get(self.cursor) - .map(inline_row_linenos) - .unwrap_or((None, None)), + let rows = self.resolve_yank_rows()?; + let lo = rows + .first() + .expect("resolve_yank_rows never returns Ok(empty)") + .1; + let hi = rows + .last() + .expect("resolve_yank_rows never returns Ok(empty)") + .1; + if lo == hi { + Ok(format!("{path}:{lo}")) + } else { + Ok(format!("{path}:{lo}-{hi}")) + } + } + + /// Shared I/O-and-notify tail for [`Self::copy_lines`]/[`Self::copy_location`]: write + /// `payload` via OSC 52 ([`crate::clipboard::write_osc52`]) and post the footer notice on + /// either outcome, worded "copied ... to clipboard" — deliberately not "clipboard updated", + /// since OSC 52 is fire-and-forget (see the `clipboard` module doc) and this can only claim + /// the bytes reached the tty, never that the terminal actually honored them. Factored out so + /// the two verbs can't drift on wording. + fn copy_payload(&mut self, payload: String) { + match crate::clipboard::write_osc52(&payload) { + Ok(()) => self.notify(format!("copied {payload} to clipboard"), Severity::Info), + Err(err) => self.notify(format!("clipboard write failed: {err}"), Severity::Error), + } + } + + /// `y` (default binding `copy-lines`): copy the active yank range's TEXT to the system + /// clipboard. See [`Self::resolve_copy_lines`] for resolution and [`Self::copy_payload`] for + /// the write. Clears the active selection on success (decision 8, matching vim's `y` and + /// [`Self::stage_selection`]'s success paths) — NOT on the resolution error path, so the user + /// can fix their selection and retry. + pub fn copy_lines(&mut self) { + let payload = match self.resolve_copy_lines() { + Ok(payload) => payload, + Err(reason) => { + self.notify(reason, Severity::Error); + return; + } }; - let line = new.or(old).ok_or("no line to copy")?; - Ok(format!("{path}:{line}")) + self.copy_payload(payload); + self.cancel_selection(); } - /// `y` (default binding `copy-path-line`): copy `path:line` for the cursor's row to the - /// system clipboard via OSC 52 ([`crate::clipboard::write_osc52`]). Resolution itself is - /// [`Self::resolve_copy_path_line`]; this wraps it with the I/O and the footer notice. - /// - /// The footer notice fires on both outcomes: success is worded "copied ... to clipboard", - /// deliberately not "clipboard updated" — OSC 52 is fire-and-forget (see the `clipboard` - /// module doc), so this can only claim the bytes reached the tty, never that the terminal - /// actually honored them. - pub fn copy_path_line(&mut self) { - let payload = match self.resolve_copy_path_line() { + /// `Y` (default binding `copy-location`): copy the active yank range's `path:line` (or + /// `path:lo-hi`) to the system clipboard. See [`Self::resolve_copy_location`] for resolution + /// and [`Self::copy_payload`] for the write. Clears the active selection on success, same as + /// [`Self::copy_lines`] — not on the resolution error path. + pub fn copy_location(&mut self) { + let payload = match self.resolve_copy_location() { Ok(payload) => payload, Err(reason) => { self.notify(reason, Severity::Error); return; } }; - match crate::clipboard::write_osc52(&payload) { - Ok(()) => self.notify(format!("copied {payload} to clipboard"), Severity::Info), - Err(err) => self.notify(format!("clipboard write failed: {err}"), Severity::Error), - } + self.copy_payload(payload); + self.cancel_selection(); } /// Park the cursor on [`Self::search_matches`]`[idx]`: auto-expand the gap it's hidden behind @@ -13787,11 +13886,11 @@ mod tests { ); } - // ── `copy-path-line` (M11) ────────────────────────────────────────────── + // ── `copy-lines` / `copy-location` (M11 yank split) ───────────────────── /// A single pure deletion — `b` (old line 2) removed with nothing added in its place — so /// the row it produces has an old lineno but NO new one, the fallback case - /// [`App::copy_path_line`]'s doc names. + /// [`resolve_yank_rows`]'s doc names. fn pure_deletion_fixture() -> Fixture { FixtureBuilder::new() .config("core.autocrlf", "false") @@ -13811,28 +13910,29 @@ mod tests { .unwrap() } - // These target `App::resolve_copy_path_line` directly rather than `App::copy_path_line` — - // resolution is pure, but `copy_path_line` itself writes to `/dev/tty` via - // `crate::clipboard::write_osc52`, which is `ENXIO` in a test harness/CI with no controlling - // tty. Asserting through the notice text would make line resolution depend on a real - // terminal for no reason; the byte-sequence tests in `clipboard.rs` cover the write side. + // These target `App::resolve_copy_location`/`App::resolve_copy_lines` directly rather than + // `App::copy_location`/`App::copy_lines` — resolution is pure, but the verbs themselves write + // to `/dev/tty` via `crate::clipboard::write_osc52`, which is `ENXIO` in a test harness/CI + // with no controlling tty. Asserting through the notice text would make line resolution + // depend on a real terminal for no reason; the byte-sequence tests in `clipboard.rs` cover + // the write side. #[test] - fn copy_path_line_uses_the_new_side_on_a_context_row_in_both_layouts() { + fn copy_location_uses_the_new_side_on_a_context_row_in_both_layouts() { let fixture = pure_addition_fixture(); let mut app = app_from_fixture(&fixture); app.open_current(); app.cursor = 0; // the leading "a" context row: old 1, new 1 - assert_eq!(app.resolve_copy_path_line(), Ok("f.txt:1".to_string())); + assert_eq!(app.resolve_copy_location(), Ok("f.txt:1".to_string())); app.toggle_layout(); app.cursor = 0; - assert_eq!(app.resolve_copy_path_line(), Ok("f.txt:1".to_string())); + assert_eq!(app.resolve_copy_location(), Ok("f.txt:1".to_string())); } #[test] - fn copy_path_line_uses_the_new_side_on_a_pure_addition_row_in_both_layouts() { + fn copy_location_uses_the_new_side_on_a_pure_addition_row_in_both_layouts() { let fixture = pure_addition_fixture(); let mut app = app_from_fixture(&fixture); app.open_current(); @@ -13845,7 +13945,7 @@ mod tests { .expect("the inserted 'b' has its own SBS row at new line 2"); app.cursor = row; - assert_eq!(app.resolve_copy_path_line(), Ok("f.txt:2".to_string())); + assert_eq!(app.resolve_copy_location(), Ok("f.txt:2".to_string())); app.toggle_layout(); let inline_row = app @@ -13856,11 +13956,11 @@ mod tests { .position(|r| matches!(r, InlineRow::Add { new: 2, .. })) .expect("the inserted 'b' has its own inline Add row"); app.cursor = inline_row; - assert_eq!(app.resolve_copy_path_line(), Ok("f.txt:2".to_string())); + assert_eq!(app.resolve_copy_location(), Ok("f.txt:2".to_string())); } #[test] - fn copy_path_line_falls_back_to_the_old_lineno_on_a_pure_deletion_row_in_both_layouts() { + fn copy_location_falls_back_to_the_old_lineno_on_a_pure_deletion_row_in_both_layouts() { let fixture = pure_deletion_fixture(); let mut app = app_from_fixture(&fixture); app.open_current(); @@ -13874,7 +13974,7 @@ mod tests { app.cursor = row; assert_eq!( - app.resolve_copy_path_line(), + app.resolve_copy_location(), Ok("f.txt:2".to_string()), "no new side on a pure deletion: falls back to the old lineno" ); @@ -13888,18 +13988,18 @@ mod tests { .position(|r| matches!(r, InlineRow::Del { old: 2, .. })) .expect("the deleted 'b' has its own inline Del row"); app.cursor = inline_row; - assert_eq!(app.resolve_copy_path_line(), Ok("f.txt:2".to_string())); + assert_eq!(app.resolve_copy_location(), Ok("f.txt:2".to_string())); } #[test] - fn copy_path_line_resolver_errs_instead_of_returning_garbage_on_a_gap_row_in_both_layouts() { + fn copy_location_resolver_errs_instead_of_returning_garbage_on_a_gap_row_in_both_layouts() { let fixture = two_hunks_with_a_wide_gap_fixture(); let mut app = app_from_fixture(&fixture); app.open_current(); app.cursor = only_gap_row(&app); assert_eq!( - app.resolve_copy_path_line(), + app.resolve_copy_location(), Err("no line to copy"), "a gap row carries neither an old nor a new lineno" ); @@ -13913,6 +14013,155 @@ mod tests { .position(|r| matches!(r, InlineRow::Gap { .. })) .expect("the same wide context run collapses to an inline Gap row too"); app.cursor = inline_gap; - assert_eq!(app.resolve_copy_path_line(), Err("no line to copy")); + assert_eq!(app.resolve_copy_location(), Err("no line to copy")); + } + + /// Multi-row selection -> content, in both layouts, over a range spanning a deletion, an + /// addition, and a context row (`two_changes_one_hunk_fixture`: `a b c d e` -> `a B c D e`). + /// SBS pairs `b`/`B` and `d`/`D` into single rows each carrying both sides, so the range + /// `[paired(b,B), context c, paired(d,D)]` resolves to the NEW side throughout (decision 4): + /// `B`, `c`, `D`. + #[test] + fn multi_row_selection_copies_content_spanning_del_add_context_sbs() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert_eq!(app.current_view_ref().unwrap().display.len(), 5); + app.cursor = 1; + app.selection_anchor = Some(1); + app.cursor = 3; + + assert_eq!(app.resolve_copy_lines(), Ok("B\nc\nD".to_string())); + } + + /// Inline analog: the same span becomes `Del(b) Add(B) Context(c) Del(d) Add(D)` — selecting + /// from the first `Add` through the second `Add` picks up `Add(B) Context(c) Del(d) Add(D)`. + /// Unlike SBS, inline is per-side precise (decision 4): the `Del(d)` row in the middle + /// contributes its OLD text (`d`), separately from the following `Add(D)`'s NEW text — this + /// is the row-precision inline exists for, not a bug. + #[test] + fn multi_row_selection_copies_content_spanning_del_add_context_inline() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.toggle_layout(); + let inline = &app.current_view_ref().unwrap().inline; + let lo = inline + .iter() + .position(|r| matches!(r, InlineRow::Add { new: 2, .. })) + .expect("the b->B add has its own inline row"); + let hi = inline + .iter() + .position(|r| matches!(r, InlineRow::Add { new: 4, .. })) + .expect("the d->D add has its own inline row"); + app.cursor = lo; + app.selection_anchor = Some(lo); + app.cursor = hi; + + assert_eq!(app.resolve_copy_lines(), Ok("B\nc\nd\nD".to_string())); + } + + /// Multi-row selection -> `path:lo-hi`, both layouts, over the same del/add/context span. + #[test] + fn multi_row_selection_copies_a_lo_hi_location_range_both_layouts() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.cursor = 1; + app.selection_anchor = Some(1); + app.cursor = 3; + + assert_eq!(app.resolve_copy_location(), Ok("f.txt:2-4".to_string())); + + app.cancel_selection(); + app.toggle_layout(); + let inline = &app.current_view_ref().unwrap().inline; + let lo = inline + .iter() + .position(|r| matches!(r, InlineRow::Add { new: 2, .. })) + .expect("the b->B add has its own inline row"); + let hi = inline + .iter() + .position(|r| matches!(r, InlineRow::Add { new: 4, .. })) + .expect("the d->D add has its own inline row"); + app.cursor = lo; + app.selection_anchor = Some(lo); + app.cursor = hi; + + assert_eq!(app.resolve_copy_location(), Ok("f.txt:2-4".to_string())); + } + + /// Single-row selection collapses to the single-line `path:12` form (decision 6), not + /// `path:12-12`. + #[test] + fn single_row_selection_collapses_to_the_single_line_location_form() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.cursor = 1; // the paired b->B row + app.selection_anchor = Some(1); + + assert_eq!(app.resolve_copy_location(), Ok("f.txt:2".to_string())); + } + + /// A selection spanning a gap row: the gap contributes nothing (decision 5), but its + /// neighbors on either side are still copied. + #[test] + fn selection_spanning_a_gap_row_skips_the_gap_but_copies_its_neighbors() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let gap_row = only_gap_row(&app); + assert!(gap_row > 0, "expects at least one row before the gap"); + app.cursor = gap_row - 1; + app.selection_anchor = Some(gap_row - 1); + app.cursor = gap_row + 1; + + let content = app + .resolve_copy_lines() + .expect("neighbors on either side of the gap still resolve"); + assert_eq!( + content.lines().count(), + 2, + "exactly the two non-gap neighbors, the gap row itself contributes nothing: {content:?}" + ); + } + + /// A range resolving to no text at all (every row a gap) errs rather than writing an empty + /// clipboard payload. + #[test] + fn an_all_gap_range_errs_instead_of_copying_empty() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let gap_row = only_gap_row(&app); + app.cursor = gap_row; + app.selection_anchor = Some(gap_row); + + assert_eq!(app.resolve_copy_lines(), Err("no line to copy")); + assert_eq!(app.resolve_copy_location(), Err("no line to copy")); + } + + /// Content yank in `Role::Combined` succeeds — locked decision 7 pins this against a future + /// "helpful" refusal: decision 4's side-selection rule is total (it always yields a side), so + /// unlike the staging verbs there is nothing to refuse. `start_selection` itself still gates + /// combined (it's a staging-shaped verb), so the selection is set directly here rather than + /// through `v`. + #[test] + fn content_yank_succeeds_in_combined_role() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.zoom = super::Zoom::Combined; + app.open_current(); + assert_eq!( + app.staging_role(), + None, + "Zoom::Combined always resolves to Role::Combined (effective_zoom)" + ); + app.cursor = 1; + app.selection_anchor = Some(1); + app.cursor = 3; + + assert_eq!(app.resolve_copy_lines(), Ok("B\nc\nD".to_string())); } } diff --git a/git-workon-review/src/clipboard.rs b/git-workon-review/src/clipboard.rs index ced67fd..8cb14b5 100644 --- a/git-workon-review/src/clipboard.rs +++ b/git-workon-review/src/clipboard.rs @@ -1,4 +1,4 @@ -//! OSC 52 clipboard writes (M11, `copy-path-line`). +//! OSC 52 clipboard writes (M11, `copy-lines`/`copy-location`). //! //! The only clipboard mechanism this crate has: an OSC 52 "set clipboard" escape sequence //! written to the controlling tty. No `arboard`-style dependency — the M11 handoff's locked @@ -14,8 +14,9 @@ //! that doesn't either ignores the sequence or (rarely) echoes stray bytes if some intermediate //! layer mishandles it — either way, nothing comes back on the wire to tell the caller which //! happened. [`write_osc52`] returning `Ok(())` therefore means only "the bytes reached -//! `/dev/tty`", never "the clipboard actually changed". [`crate::app::App::copy_path_line`]'s -//! footer notice is worded to match: "copied ... to clipboard", never "clipboard updated" — +//! `/dev/tty`", never "the clipboard actually changed". [`crate::app::App::copy_lines`]'s and +//! [`crate::app::App::copy_location`]'s shared footer notice is worded to match: "copied ... to +//! clipboard", never "clipboard updated" — //! the latter phrasing is a claim a silent failure could falsify. //! //! ## Known gaps (deliberately deferred) diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index f0c9ca2..cce7a22 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -75,7 +75,8 @@ pub enum Command { Search, SearchNext, SearchPrev, - CopyPathLine, + CopyLines, + CopyLocation, // Diff view. FocusOutline, // Outline view. @@ -373,11 +374,18 @@ pub static REGISTRY: &[Registered] = &[ description: "Previous search match (or previous hunk, when no search is active)", }, Registered { - command: Command::CopyPathLine, + command: Command::CopyLines, view: View::Diff, - name: "copy-path-line", + name: "copy-lines", default_keys: "y", - description: "Copy path:line for the cursor row to the clipboard", + description: "Copy the selected (or cursor) lines' text to the clipboard", + }, + Registered { + command: Command::CopyLocation, + view: View::Diff, + name: "copy-location", + default_keys: "Y", + description: "Copy path:line (or path:lo-hi) for the selected rows to the clipboard", }, // ── Outline view ───────────────────────────────────────────────────────── Registered { @@ -1383,21 +1391,27 @@ mod tests { ); } - /// M11 (`copy-path-line`): `y` was free in both `View::Global` and `View::Diff` (unlike `p`, - /// which `[h`'s extra default and the outline's `prev-changeset` already claim), so no - /// existing binding needed to move to make room for it — unlike `cycle-zoom`'s `z` -> `Z` - /// rebind above. Pins the resolved default clash-free the same way those tests do. + /// M11 (`copy-lines`/`copy-location`, the yank split): `y` was free in both `View::Global` + /// and `View::Diff` (unlike `p`, which `[h`'s extra default and the outline's + /// `prev-changeset` already claim), so no existing binding needed to move to make room for + /// it — unlike `cycle-zoom`'s `z` -> `Z` rebind above. `Y` is likewise free (verified against + /// every `default_keys` entry in this registry when the yank split was designed). Pins both + /// resolved defaults clash-free the same way those tests do. #[test] - fn y_dispatches_copy_path_line_with_no_collisions() { + fn y_dispatches_copy_lines_and_shift_y_dispatches_copy_location_with_no_collisions() { let km = Keymap::defaults(); assert!( km.warnings().is_empty(), - "copy-path-line's default `y` must not collide with anything: {:?}", + "copy-lines/copy-location's default `y`/`Y` must not collide with anything: {:?}", km.warnings() ); assert_eq!( feed(&km, false, &[key(KeyCode::Char('y'))]), - Dispatch::Command(Command::CopyPathLine) + Dispatch::Command(Command::CopyLines) + ); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('Y'))]), + Dispatch::Command(Command::CopyLocation) ); } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index b673cdc..eec3e0e 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -473,7 +473,8 @@ enum Action { SearchFocus, SearchNext, SearchPrev, - CopyPathLine, + CopyLines, + CopyLocation, None, } @@ -512,7 +513,8 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::Search => Action::SearchFocus, Command::SearchNext => Action::SearchNext, Command::SearchPrev => Action::SearchPrev, - Command::CopyPathLine => Action::CopyPathLine, + Command::CopyLines => Action::CopyLines, + Command::CopyLocation => Action::CopyLocation, Command::NextFile => Action::NextFile, Command::PrevFile => Action::PrevFile, Command::NextHunk => Action::NextHunk, @@ -688,7 +690,8 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::SearchFocus => app.search_focus(), Action::SearchNext => app.search_next(), Action::SearchPrev => app.search_prev(), - Action::CopyPathLine => app.copy_path_line(), + Action::CopyLines => app.copy_lines(), + Action::CopyLocation => app.copy_location(), Action::None => {} } false From fdfa8bb4be3104bfb2fd0b452ab0621c11017140 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 27 Jul 2026 18:55:57 -0400 Subject: [PATCH 201/203] fix(review): keep selection when the clipboard write fails --- git-workon-review/src/app.rs | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 4f32223..8175a6e 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -4026,18 +4026,28 @@ impl App { /// since OSC 52 is fire-and-forget (see the `clipboard` module doc) and this can only claim /// the bytes reached the tty, never that the terminal actually honored them. Factored out so /// the two verbs can't drift on wording. - fn copy_payload(&mut self, payload: String) { + /// + /// Returns whether the write succeeded, so the callers can honor decision 8's "clear the + /// selection on success" precisely: a failed write must LEAVE the selection intact, or the + /// user loses the range they built and has no way to retry the thing that just failed. + fn copy_payload(&mut self, payload: String) -> bool { match crate::clipboard::write_osc52(&payload) { - Ok(()) => self.notify(format!("copied {payload} to clipboard"), Severity::Info), - Err(err) => self.notify(format!("clipboard write failed: {err}"), Severity::Error), + Ok(()) => { + self.notify(format!("copied {payload} to clipboard"), Severity::Info); + true + } + Err(err) => { + self.notify(format!("clipboard write failed: {err}"), Severity::Error); + false + } } } /// `y` (default binding `copy-lines`): copy the active yank range's TEXT to the system /// clipboard. See [`Self::resolve_copy_lines`] for resolution and [`Self::copy_payload`] for /// the write. Clears the active selection on success (decision 8, matching vim's `y` and - /// [`Self::stage_selection`]'s success paths) — NOT on the resolution error path, so the user - /// can fix their selection and retry. + /// [`Self::stage_selection`]'s success paths) — NOT on either failure path (resolution error + /// or a failed clipboard write), so the user keeps the range they built and can retry. pub fn copy_lines(&mut self) { let payload = match self.resolve_copy_lines() { Ok(payload) => payload, @@ -4046,14 +4056,15 @@ impl App { return; } }; - self.copy_payload(payload); - self.cancel_selection(); + if self.copy_payload(payload) { + self.cancel_selection(); + } } /// `Y` (default binding `copy-location`): copy the active yank range's `path:line` (or /// `path:lo-hi`) to the system clipboard. See [`Self::resolve_copy_location`] for resolution /// and [`Self::copy_payload`] for the write. Clears the active selection on success, same as - /// [`Self::copy_lines`] — not on the resolution error path. + /// [`Self::copy_lines`] — not on either failure path. pub fn copy_location(&mut self) { let payload = match self.resolve_copy_location() { Ok(payload) => payload, @@ -4062,8 +4073,9 @@ impl App { return; } }; - self.copy_payload(payload); - self.cancel_selection(); + if self.copy_payload(payload) { + self.cancel_selection(); + } } /// Park the cursor on [`Self::search_matches`]`[idx]`: auto-expand the gap it's hidden behind From f5d9e507595d9c080c9c034aa93026fedd61a30b Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 27 Jul 2026 18:57:35 -0400 Subject: [PATCH 202/203] style(review): use matches! for search-side render filter --- git-workon-review/src/render.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index cf537cd..47bc6cd 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -796,11 +796,13 @@ fn search_bg_spans( SearchRenderSide::Old => old_lineno.is_some() && m.old_lineno == old_lineno, SearchRenderSide::New => new_lineno.is_some() && m.new_lineno == new_lineno, }) - .filter(|(_, m)| match (m.side, render_side) { - (SearchSide::Both, _) => true, - (SearchSide::Old, SearchRenderSide::Old) => true, - (SearchSide::New, SearchRenderSide::New) => true, - _ => false, + .filter(|(_, m)| { + matches!( + (m.side, render_side), + (SearchSide::Both, _) + | (SearchSide::Old, SearchRenderSide::Old) + | (SearchSide::New, SearchRenderSide::New) + ) }) .map(|(i, m)| { let color = if Some(i) == current { From 69a1e9a8ec8dcacf992846bf06b70e06794d54b8 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 29 Jul 2026 19:20:51 -0400 Subject: [PATCH 203/203] docs(review): drop stale detect_gt note from pty bound --- git-workon-review/tests/pty/pty_responsiveness.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/git-workon-review/tests/pty/pty_responsiveness.rs b/git-workon-review/tests/pty/pty_responsiveness.rs index e422691..448d838 100644 --- a/git-workon-review/tests/pty/pty_responsiveness.rs +++ b/git-workon-review/tests/pty/pty_responsiveness.rs @@ -126,8 +126,8 @@ fn launch_reaches_the_tui_and_quits_promptly() { // Theme pinned to dark so the `theme = auto` probe (and its deadline) stays out of this // bound — the probe's own responsiveness is pty_smoke.rs's job. One unstaged change so the // TUI actually opens; a plain (non-Graphite) repo keeps behavior identical whether or not - // the machine has `gt` on PATH — and `StackModel::detect` still runs `detect_gt` first, so - // a reintroduced subprocess spawn there is still inside the measured window. + // the machine has `gt` on PATH. `StackModel::detect` no longer probes for `gt` at all, so + // this bound no longer covers that lookup — it stays a pure metadata check. let fixture = FixtureBuilder::new() .config("workon.review.theme", "dark") .unstaged_file("file.txt", "a\nb\nc\n", "a\nCHANGED\nc\n")