diff --git a/Cargo.lock b/Cargo.lock index 3f4c2ed..6bec133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -73,6 +79,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "2.13.2" @@ -90,6 +108,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -167,6 +197,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -187,6 +223,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -215,6 +260,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "darling" version = "0.24.1" @@ -303,6 +354,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -320,6 +386,17 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -343,6 +420,27 @@ dependencies = [ "wasi", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -453,12 +551,47 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +[[package]] +name = "icy_sixel" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc0a9c4770bc47b0a933256a496cfb8b6531f753ea9bccb19c6dff0ff7273fc" + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "include_dir" version = "0.7.4" @@ -545,14 +678,17 @@ dependencies = [ "clap", "crossterm", "dirs", + "image", "include_dir", "libc", "once_cell", "ratatui", + "ratatui-image", "serde", "serde_json", "serde_yaml", "similar", + "strip-ansi-escapes", "textwrap", "thiserror 2.0.20", "tokio", @@ -641,6 +777,26 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.3" @@ -653,6 +809,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -662,6 +828,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -715,6 +890,19 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -726,6 +914,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -735,6 +932,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.47" @@ -744,6 +953,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "ratatui" version = "0.29.0" @@ -765,6 +1004,22 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "ratatui-image" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8fe71c551c67f34e4fa49797227f614cd064b82855d7b72d424e40d08ec0542" +dependencies = [ + "base64", + "icy_sixel", + "image", + "rand", + "ratatui", + "rustix", + "thiserror 1.0.69", + "windows", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -947,6 +1202,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "similar" version = "2.7.0" @@ -997,6 +1258,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1118,6 +1388,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -1430,12 +1714,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -1458,12 +1757,76 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -1575,6 +1938,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -1629,8 +2012,29 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index f4d0645..084f991 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ clap = { version = "4", features = ["derive"] } crossterm = { version = "0.28", features = ["event-stream", "libc", "use-dev-tty"] } dirs = "6" ratatui = "0.29" +ratatui-image = { version = "=8.0.1", default-features = false } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "ico", "tiff"] } serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" serde_json = "1" @@ -43,6 +45,7 @@ unicode-width = "0.2" tui-textarea = { version = "0.7", features = ["crossterm"] } textwrap = "0.16.2" libc = "0.2" +strip-ansi-escapes = "0.2" [profile.release] lto = true diff --git a/README.md b/README.md index 6603148..f92b0e0 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,44 @@ Then run: lazygitrs ``` +Use `lazygitrs --commits` to start at the checked-out HEAD commit. Press +`Ctrl+G` to toggle between working-tree changes and HEAD (including from a +focused diff). If filters or pagination hide HEAD, it opens a separate HEAD +history view without clearing your filters. Remap or disable the shortcut with +`keybinding.universal.toggleWorkingTreeAndHead` (`""` by default; `""` disables it). + +### File tree navigation + +In Files, commit/stash file lists, and compare mode, press backtick (`` ` ``) to +toggle the tree view. With a tree active: + +- `-` folds/unfolds the selected directory (including the root). +- `Enter` focuses the selected file or combined directory diff without changing + your normal/half/full layout; `Esc` returns to the file list. +- `,` / `.` select the parent / first visible child. +- `<` / `>` select the previous / next sibling, skipping nested descendants. + Hierarchy navigation also works while the diff is focused. + +These shortcuts are configurable under `keybinding.universal` in your config: + +```yaml +keybinding: + universal: + foldDirectory: "-" + treeParent: "," + treeChild: "." + treePrevSibling: "<" + treeNextSibling: ">" +``` + +Set a binding to `""` to disable it. Collapsed directories must be unfolded +before their children can be selected. Tree-navigation actions appear in `?` +only while a tree is active. The footer shows just the fold/unfold shortcut +(default `-`) in tree views, not hierarchy navigation or commit details. +`'` (apostrophe) toggles commit details, also shown on the panel’s top-right +border; explicitly remapping a tree action to `'` will override it while +the tree is active. + ### Upgrade Detects how you installed (brew / npm / bun / cargo / install.sh) and upgrades in place: @@ -74,6 +112,30 @@ lazygitrs upgrade 0.0.32 # specific version - [x] 🎨 Themes + Theme-Picker! - [x] **Grep diff contents** — `Ctrl-F` in Files / Commit Files / Compare searches hunk lines in-context, `Enter` jumps to the file in the current list. +### Image diffs + +Selecting a PNG, JPEG, GIF (first frame), WebP, BMP, ICO, or TIFF file shows +**Before / After** previews in Kitty and Ghostty. Added/deleted files show the +available image full-width, labeled **Added** or **Deleted**. Staged previews use the index; +commit-file and ref comparisons use the corresponding Git blobs. + +Other binaries, unsupported/corrupt images, files over 20 MiB, and unsupported +terminal environments keep the striped binary placeholder. Images are decoded +with memory/dimension limits and resized in the background. SVG and video +previews are not included yet. + +Folder previews in the file tree mix text diffs with inline image sections. +Each image section is capped at 12 rows; visible sections load in the background +(two at a time) and offscreen pixels are released. This works in split/unified +and wrapped views, including commit/stash folders and ref-comparison folders. +Whole-commit overview buffers still retain binary placeholders. Inline images +require Kitty/Ghostty placeholder graphics; other terminals keep stripes. + +Set `LAZYGITRS_IMAGE_PREVIEW=off` to disable image previews. tmux, screen, and +Zellij currently fall back to stripes. iTerm2/WezTerm and Sixel are experimental +(`LAZYGITRS_IMAGE_PREVIEW=experimental`); their overlay cleanup is not yet fully +verified. Nested editor launches avoid terminal capability queries. + ### Configuration Config goes in `~/.config/lazygitrs/config.yml` or `~/.config/lazygit/config.yml` — both work, using either only won't break anything so you can reference the [original lazygit config guide](https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md). diff --git a/src/app.rs b/src/app.rs index be1b613..e695639 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,10 +10,16 @@ pub struct App { pub config: AppConfig, pub repo_path: PathBuf, pub filter_path: Option, + pub start_in_commits: bool, } impl App { - pub fn new(repo_path: PathBuf, debug: bool, filter_path: Option) -> Result { + pub fn new( + repo_path: PathBuf, + debug: bool, + filter_path: Option, + start_in_commits: bool, + ) -> Result { let config = AppConfig::load(debug)?; // Validate git repo @@ -25,6 +31,7 @@ impl App { config, repo_path, filter_path, + start_in_commits, }) } @@ -36,9 +43,9 @@ impl App { self.config.app_state.add_recent_repo(&repo_str); let _ = self.config.save_state(); - // Pass `-f` into Gui::new so the initial model stream loads filtered - // commits immediately (no wait for full refresh + second git log). - let mut gui = Gui::new(self.config, git, self.filter_path)?; + // Pass `-f` and start_in_commits into Gui::new so the initial model stream loads + // filtered commits or commits tab immediately. + let mut gui = Gui::new(self.config, git, self.filter_path, self.start_in_commits)?; gui.run()?; Ok(()) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 0b992f6..77fe68c 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -126,6 +126,18 @@ pub struct UniversalKeybinding { pub reset_side_panel: String, #[serde(rename = "toggleDiffViewLayout")] pub toggle_diff_view_layout: String, + #[serde(rename = "toggleWorkingTreeAndHead")] + pub toggle_working_tree_and_head: String, + #[serde(rename = "foldDirectory")] + pub fold_directory: String, + #[serde(rename = "treeParent")] + pub tree_parent: String, + #[serde(rename = "treeChild")] + pub tree_child: String, + #[serde(rename = "treePrevSibling")] + pub tree_prev_sibling: String, + #[serde(rename = "treeNextSibling")] + pub tree_next_sibling: String, } impl Default for UniversalKeybinding { @@ -181,6 +193,12 @@ impl Default for UniversalKeybinding { main_panel_full: "".into(), reset_side_panel: "".into(), toggle_diff_view_layout: "\\".into(), + toggle_working_tree_and_head: "".into(), + fold_directory: "-".into(), + tree_parent: ",".into(), + tree_child: ".".into(), + tree_prev_sibling: "<".into(), + tree_next_sibling: ">".into(), } } } @@ -448,3 +466,33 @@ pub fn parse_key(s: &str) -> Option { None } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tree_keybindings_defaults_and_deserialization() { + let default_config = KeybindingConfig::default(); + assert_eq!(default_config.universal.fold_directory, "-"); + assert_eq!(default_config.universal.tree_parent, ","); + assert_eq!(default_config.universal.tree_child, "."); + assert_eq!(default_config.universal.tree_prev_sibling, "<"); + assert_eq!(default_config.universal.tree_next_sibling, ">"); + + let yaml = r#" +universal: + foldDirectory: "_" + treeParent: "p" + treeChild: "c" + treePrevSibling: "h" + treeNextSibling: "l" +"#; + let parsed: KeybindingConfig = serde_yaml::from_str(yaml).expect("valid yaml"); + assert_eq!(parsed.universal.fold_directory, "_"); + assert_eq!(parsed.universal.tree_parent, "p"); + assert_eq!(parsed.universal.tree_child, "c"); + assert_eq!(parsed.universal.tree_prev_sibling, "h"); + assert_eq!(parsed.universal.tree_next_sibling, "l"); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 8898b66..abfdd5b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -31,6 +31,20 @@ pub struct AppConfig { pub state_path: PathBuf, } +impl Default for AppConfig { + fn default() -> Self { + Self { + debug: false, + version: env!("CARGO_PKG_VERSION").to_string(), + user_config: UserConfig::default(), + app_state: AppState::default(), + config_dir: PathBuf::from("."), + state_dir: PathBuf::from("."), + state_path: PathBuf::from("state.yml"), + } + } +} + impl AppConfig { pub fn load(debug: bool) -> Result { let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); diff --git a/src/git/commit.rs b/src/git/commit.rs index 9e2fdce..ab484d0 100644 --- a/src/git/commit.rs +++ b/src/git/commit.rs @@ -94,6 +94,40 @@ impl GitCommands { self.load_filtered_commits_page(&CommitFilter::default(), limit, skip) } + /// Load the symmetric difference of two commit revisions in topological order. + /// Left marks A-only commits; Right marks B-only commits. A zero limit is + /// unlimited, matching the other paged commit loaders. + pub fn load_compare_commits( + &self, + ref_a: &str, + ref_b: &str, + limit: usize, + skip: usize, + ) -> Result> { + let range = format!( + "{}...{}", + self.resolve_commit_ref(ref_a)?, + self.resolve_commit_ref(ref_b)? + ); + // NUL field separators preserve pipes in commit subjects and identities. + // %m is '<' for the left side and '>' for the right side of A...B. + let mut cmd = self.git().args(&[ + "log", + "--left-right", + "--topo-order", + "--no-show-signature", + "--format=%H%x00%s%x00%an%x00%ae%x00%at%x00%P%x00%D%x00%m", + ]); + if limit > 0 { + cmd = cmd.arg(&format!("--max-count={limit}")); + } + if skip > 0 { + cmd = cmd.arg(&format!("--skip={skip}")); + } + let result = cmd.arg(&range).arg("--").run_expecting_success()?; + self.parse_commit_log_fields(&result, '\0') + } + pub fn load_filtered_commits_page( &self, filter: &CommitFilter, @@ -165,6 +199,14 @@ impl GitCommands { } fn parse_commit_log(&self, result: &crate::os::cmd::CmdResult) -> Result> { + self.parse_commit_log_fields(result, '|') + } + + fn parse_commit_log_fields( + &self, + result: &crate::os::cmd::CmdResult, + separator: char, + ) -> Result> { if !result.success { return Ok(Vec::new()); } @@ -173,7 +215,9 @@ impl GitCommands { // `git log @{u}..HEAD` per page parse (filter apply hot path). let mut commits = Vec::new(); for line in result.stdout.lines() { - let parts: Vec<&str> = line.splitn(7, '|').collect(); + let parts: Vec<&str> = line + .splitn(if separator == '\0' { 8 } else { 7 }, separator) + .collect(); if parts.len() < 6 { continue; } @@ -201,7 +245,11 @@ impl GitCommands { author_email, unix_timestamp, parents, - divergence: Divergence::None, + divergence: match parts.get(7).copied() { + Some("<") => Divergence::Left, + Some(">") => Divergence::Right, + _ => Divergence::None, + }, }); } diff --git a/src/git/diff.rs b/src/git/diff.rs index 0393cb7..09b848d 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -360,6 +360,30 @@ impl GitCommands { } } + /// Counts commits unique to A and B, respectively (A's ahead/behind B). + /// Both refs must resolve to commits; tree-only comparisons have no ancestry. + pub fn diff_refs_ahead_behind(&self, ref_a: &str, ref_b: &str) -> Result<(usize, usize)> { + let range = format!( + "{}...{}", + self.resolve_commit_ref(ref_a)?, + self.resolve_commit_ref(ref_b)? + ); + let result = self + .git() + .args(&["rev-list", "--left-right", "--count", &range, "--"]) + .run_expecting_success()?; + let mut counts = result.stdout.split_whitespace(); + let ahead = counts + .next() + .ok_or_else(|| anyhow::anyhow!("Missing ahead count"))? + .parse()?; + let behind = counts + .next() + .ok_or_else(|| anyhow::anyhow!("Missing behind count"))? + .parse()?; + Ok((ahead, behind)) + } + /// Get the list of files changed between two refs (for diff/compare mode). pub fn diff_refs_files( &self, @@ -486,7 +510,7 @@ impl GitCommands { } } -fn diff_paths_for_label(path: &str) -> Vec<&str> { +pub(crate) fn diff_paths_for_label(path: &str) -> Vec<&str> { match path.split_once(" -> ") { Some((old, new)) => vec![old, new], None => vec![path], diff --git a/src/git/diff_paths.rs b/src/git/diff_paths.rs new file mode 100644 index 0000000..6f34812 --- /dev/null +++ b/src/git/diff_paths.rs @@ -0,0 +1,491 @@ +//! Parse file paths from `git diff` output. +//! +//! Only the first file section's headers are inspected. Hunk bodies (from the +//! first `@@` line on) are never read so hunk content that looks like +//! metadata cannot be mistaken for headers. + +/// Old (`a/` side) and new (`b/` side) paths for the first file in a diff. +/// +/// `None` means that side is `/dev/null`: +/// - new file → `old` is `None` +/// - deleted file → `new` is `None` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffFilePaths { + pub old: Option, + pub new: Option, +} + +/// Extract the first file section's paths from `git diff` output. +/// +/// Looks at the first `diff --git a/… b/…` header (including C-style quoted +/// paths and unquoted paths with spaces), then applies `rename from/to`, +/// `copy from/to`, `---`/`+++` (`/dev/null` → `None`) and +/// `new file mode` (`old` → `None`) / `deleted file mode` (`new` → `None`) +/// overrides. Stops at the first `@@` hunk header and at the next +/// `diff --git` (second file). Returns `None` when no headers are found. +pub(crate) fn paths_from_diff(diff: &str) -> Option { + let mut old: Option = None; + let mut new: Option = None; + let mut seen_diff_git = false; + let mut seen_any = false; + + for line in diff.lines() { + if line.starts_with("diff --git ") { + if seen_diff_git { + break; + } + if let Some((o, n)) = parse_diff_git_line(line) { + old = Some(o); + new = Some(n); + } + seen_diff_git = true; + seen_any = true; + continue; + } + + if line.starts_with("@@") { + break; + } + + if let Some(rest) = line.strip_prefix("rename from ") { + if let Some(p) = decode_single_path(rest) { + old = Some(p); + seen_any = true; + } + continue; + } + if let Some(rest) = line.strip_prefix("rename to ") { + if let Some(p) = decode_single_path(rest) { + new = Some(p); + seen_any = true; + } + continue; + } + if let Some(rest) = line.strip_prefix("copy from ") { + if let Some(p) = decode_single_path(rest) { + old = Some(p); + seen_any = true; + } + continue; + } + if let Some(rest) = line.strip_prefix("copy to ") { + if let Some(p) = decode_single_path(rest) { + new = Some(p); + seen_any = true; + } + continue; + } + + if is_mode_line(line, "new file mode") { + old = None; + seen_any = true; + continue; + } + if is_mode_line(line, "deleted file mode") { + new = None; + seen_any = true; + continue; + } + + if let Some(rest) = line.strip_prefix("--- ") { + if let Some(opt) = parse_ext_path(rest, "a/") { + old = opt; + seen_any = true; + } + continue; + } + if let Some(rest) = line.strip_prefix("+++ ") { + if let Some(opt) = parse_ext_path(rest, "b/") { + new = opt; + seen_any = true; + } + continue; + } + + if line.starts_with("Binary files ") || line.starts_with("Binary file ") { + // Binary marker carries no paths beyond `/dev/null` handling. + // Synthetic binary diffs still provide `diff --git` + file modes, + // so this only needs to cover `/dev/null` sides. + if line.starts_with("Binary files /dev/null and ") + || line.starts_with("Binary file /dev/null and ") + { + old = None; + seen_any = true; + } else if line.contains(" and /dev/null differ") { + new = None; + seen_any = true; + } + continue; + } + } + + if !seen_any { + return None; + } + Some(DiffFilePaths { old, new }) +} + +/// Return the new (`b/` side) path for a diff header. +/// +/// Accepts either a single `diff --git …` line or a full (single-file) diff. +/// Falls back to the old path for deleted files so callers always get a +/// filename when one is known. +pub(crate) fn new_path_from_header(header: &str) -> Option { + let paths = paths_from_diff(header)?; + paths.new.or(paths.old) +} + +fn is_mode_line(line: &str, kind: &str) -> bool { + if let Some(rest) = line.strip_prefix(kind) { + rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') + } else { + false + } +} + +fn strip_prefix_or_raw(path: &str, prefix: &str) -> String { + if let Some(rest) = path.strip_prefix(prefix) { + rest.to_string() + } else { + path.to_string() + } +} + +/// Decode a lone path token (for `rename from/to`, `copy from/to`). +/// C-quoted (`"…"`) tokens are C-unescaped; anything else is kept byte for +/// byte (no trimming) so trailing spaces survive. +fn decode_single_path(rest: &str) -> Option { + if rest.is_empty() { + return None; + } + if rest.starts_with('"') { + let (decoded, _) = parse_c_quoted(rest)?; + Some(decoded) + } else { + Some(rest.to_string()) + } +} + +/// Parse an extended-header path (`---`/`+++` remainder after the marker). +/// Returns `None` for malformed input, `Some(None)` for `/dev/null`, +/// `Some(Some(path))` otherwise. `prefix` is `a/` for `---`, `b/` for `+++`. +fn parse_ext_path(rest: &str, prefix: &str) -> Option> { + if rest.is_empty() { + return None; + } + if rest.starts_with('"') { + let (decoded, _) = parse_c_quoted(rest)?; + if decoded == "/dev/null" { + return Some(None); + } + return Some(Some(strip_prefix_or_raw(&decoded, prefix))); + } + let path_part = rest.split('\t').next().unwrap_or(rest); + if path_part == "/dev/null" { + return Some(None); + } + Some(Some(strip_prefix_or_raw(path_part, prefix))) +} + +fn parse_diff_git_line(line: &str) -> Option<(String, String)> { + let rest = line.strip_prefix("diff --git ")?; + if rest.is_empty() { + return None; + } + if rest.starts_with('"') { + let (first_raw, after_first) = parse_c_quoted(rest)?; + let after = after_first.strip_prefix(' ')?; + if after.is_empty() { + return None; + } + if after.starts_with('"') { + let (second_raw, _) = parse_c_quoted(after)?; + let old = strip_prefix_or_raw(&first_raw, "a/"); + let new = strip_prefix_or_raw(&second_raw, "b/"); + Some((old, new)) + } else { + let old = strip_prefix_or_raw(&first_raw, "a/"); + let new = strip_prefix_or_raw(after, "b/"); + Some((old, new)) + } + } else { + let mut candidates = Vec::new(); + for (idx, _) in rest.match_indices(" b/") { + candidates.push(idx); + } + if candidates.is_empty() { + return None; + } + if candidates.len() == 1 { + let idx = candidates[0]; + let left = &rest[..idx]; + let right = &rest[idx + 3..]; + let old = strip_prefix_or_raw(left, "a/"); + return Some((old, right.to_string())); + } + for idx in &candidates { + let left = &rest[..*idx]; + let right = &rest[idx + 3..]; + let old_cand = strip_prefix_or_raw(left, "a/"); + if old_cand == right { + return Some((old_cand, right.to_string())); + } + } + let idx = candidates[0]; + let left = &rest[..idx]; + let right = &rest[idx + 3..]; + let old = strip_prefix_or_raw(left, "a/"); + Some((old, right.to_string())) + } +} + +/// Parse a C-style quoted string starting at the opening `"`. +/// Returns the decoded string plus the remainder after the closing quote. +fn parse_c_quoted(input: &str) -> Option<(String, &str)> { + if !input.starts_with('"') { + return None; + } + let bytes = input.as_bytes(); + let mut out: Vec = Vec::new(); + let mut i = 1; + while i < bytes.len() { + let b = bytes[i]; + if b == b'"' { + let remainder = &input[i + 1..]; + let s = String::from_utf8_lossy(&out).into_owned(); + return Some((s, remainder)); + } else if b == b'\\' { + i += 1; + if i >= bytes.len() { + return None; + } + let e = bytes[i]; + match e { + b'n' => out.push(b'\n'), + b't' => out.push(b'\t'), + b'r' => out.push(b'\r'), + b'a' => out.push(0x07), + b'b' => out.push(0x08), + b'f' => out.push(0x0C), + b'v' => out.push(0x0B), + b'\\' => out.push(b'\\'), + b'"' => out.push(b'"'), + b'0'..=b'7' => { + let mut val: u32 = (e - b'0') as u32; + let mut count = 1; + while count < 3 + && i + 1 < bytes.len() + && bytes[i + 1] >= b'0' + && bytes[i + 1] <= b'7' + { + i += 1; + val = val * 8 + (bytes[i] - b'0') as u32; + count += 1; + } + out.push((val & 0xFF) as u8); + } + _ => out.push(e), + } + } else { + out.push(b); + } + i += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header_diff(body: &str) -> String { + body.to_string() + } + + #[test] + fn unquoted_simple() { + let diff = header_diff( + "diff --git a/foo.txt b/foo.txt\nindex abc..def 100644\n--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n", + ); + let p = paths_from_diff(&diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("foo.txt")); + assert_eq!(p.new.as_deref(), Some("foo.txt")); + } + + #[test] + fn unquoted_with_spaces() { + let diff = "diff --git a/foo bar/baz qux.txt b/foo bar/baz qux.txt\n--- a/foo bar/baz qux.txt\n+++ b/foo bar/baz qux.txt\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("foo bar/baz qux.txt")); + assert_eq!(p.new.as_deref(), Some("foo bar/baz qux.txt")); + assert_eq!( + new_path_from_header("diff --git a/foo bar/baz qux.txt b/foo bar/baz qux.txt"), + Some("foo bar/baz qux.txt".to_string()) + ); + } + + #[test] + fn filename_containing_b_slash_uses_equality() { + // Raw path is `x b/y`; the separator is ambiguous without equality. + let diff = + "diff --git a/x b/y b/x b/y\n--- a/x b/y\n+++ b/x b/y\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("x b/y")); + assert_eq!(p.new.as_deref(), Some("x b/y")); + assert_eq!( + new_path_from_header("diff --git a/x b/y b/x b/y"), + Some("x b/y".to_string()) + ); + } + + #[test] + fn escaped_quote_and_backslash() { + // File `foo"bar\baz` + let diff = "diff --git \"a/foo\\\"bar\\\\baz\" \"b/foo\\\"bar\\\\baz\"\n--- \"a/foo\\\"bar\\\\baz\"\n+++ \"b/foo\\\"bar\\\\baz\"\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("foo\"bar\\baz")); + assert_eq!(p.new.as_deref(), Some("foo\"bar\\baz")); + } + + #[test] + fn escaped_newline_and_tab() { + // File `foo\nbar\tbaz` (real newline/tab via escapes) + let diff = "diff --git \"a/foo\\nbar\\tbaz\" \"b/foo\\nbar\\tbaz\"\n--- \"a/foo\\nbar\\tbaz\"\n+++ \"b/foo\\nbar\\tbaz\"\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("foo\nbar\tbaz")); + assert_eq!(p.new.as_deref(), Some("foo\nbar\tbaz")); + } + + #[test] + fn utf8_octal_decodes() { + // `caf\u{e9}` with é as octal bytes \303\251 + let diff = "diff --git \"a/caf\\303\\251.txt\" \"b/caf\\303\\251.txt\"\n--- \"a/caf\\303\\251.txt\"\n+++ \"b/caf\\303\\251.txt\"\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("café.txt")); + assert_eq!(p.new.as_deref(), Some("café.txt")); + } + + #[test] + fn added_file() { + let diff = "diff --git a/new.txt b/new.txt\nnew file mode 100644\nindex 0000000..abc\n--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1 @@\n+hello\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old, None); + assert_eq!(p.new.as_deref(), Some("new.txt")); + } + + #[test] + fn deleted_file() { + let diff = "diff --git a/old.txt b/old.txt\ndeleted file mode 100644\nindex abc..0000000\n--- a/old.txt\n+++ /dev/null\n@@ -1 +0,0 @@\n-hello\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("old.txt")); + assert_eq!(p.new, None); + // new_path falls back to old so deleted files still have a name. + assert_eq!(new_path_from_header(diff), Some("old.txt".to_string())); + } + + #[test] + fn new_file_mode_without_dashes_binary_style() { + let diff = "diff --git a/img.png b/img.png\nnew file mode 100644\nindex 0000000..abc\nBinary files /dev/null and b/img.png differ\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old, None); + assert_eq!(p.new.as_deref(), Some("img.png")); + } + + #[test] + fn renamed_paths_override() { + let diff = "diff --git a/old name.txt b/new name.txt\nsimilarity index 90%\nrename from old name.txt\nrename to new name.txt\n--- a/old name.txt\n+++ b/new name.txt\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("old name.txt")); + assert_eq!(p.new.as_deref(), Some("new name.txt")); + assert_eq!(new_path_from_header(diff), Some("new name.txt".to_string())); + } + + #[test] + fn renamed_quoted_override() { + let diff = "diff --git \"a/foo\\\"old\" \"b/foo\\\"new\"\nsimilarity index 90%\nrename from \"foo\\\"old\"\nrename to \"foo\\\"new\"\n--- \"a/foo\\\"old\"\n+++ \"b/foo\\\"new\"\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("foo\"old")); + assert_eq!(p.new.as_deref(), Some("foo\"new")); + } + + #[test] + fn rename_only_without_dashes() { + let diff = "diff --git a/old.txt b/new.txt\nsimilarity index 100%\nrename from old.txt\nrename to new.txt\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("old.txt")); + assert_eq!(p.new.as_deref(), Some("new.txt")); + } + + #[test] + fn copy_from_to_override() { + let diff = "diff --git a/orig.txt b/copy.txt\nsimilarity index 95%\ncopy from orig.txt\ncopy to copy.txt\n--- a/orig.txt\n+++ b/copy.txt\n@@ -1 +1 @@\n-x\n+y\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("orig.txt")); + assert_eq!(p.new.as_deref(), Some("copy.txt")); + } + + #[test] + fn hunk_lines_pretending_metadata_are_ignored() { + let diff = "diff --git a/real.txt b/real.txt\n--- a/real.txt\n+++ b/real.txt\n@@ -1,3 +1,3 @@\n context\ndiff --git a/fake b/fake\n--- a/fake\n+++ b/fake\n-rename from fake\n-rename to fake\n-new file mode 100644\n-deleted file mode 100644\n+newcontent\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("real.txt")); + assert_eq!(p.new.as_deref(), Some("real.txt")); + } + + #[test] + fn hunk_minus_plus_pretending_headers_ignored() { + let diff = "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n---- a/fake\n-+++ b/fake\n old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("a.txt")); + assert_eq!(p.new.as_deref(), Some("a.txt")); + } + + #[test] + fn no_headers_returns_none() { + assert_eq!(paths_from_diff(""), None); + assert_eq!(paths_from_diff("just some text\nno diff here\n"), None); + assert_eq!(new_path_from_header("nothing"), None); + } + + #[test] + fn first_section_wins_for_multi_file() { + let diff = "diff --git a/first.txt b/first.txt\n--- a/first.txt\n+++ b/first.txt\n@@ -1 +1 @@\n-a\n+b\ndiff --git a/second.txt b/second.txt\n--- a/second.txt\n+++ b/second.txt\n@@ -1 +1 @@\n-c\n+d\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("first.txt")); + assert_eq!(p.new.as_deref(), Some("first.txt")); + } + + #[test] + fn trailing_spaces_preserved() { + let diff = "diff --git a/foo b/foo \n--- a/foo \n+++ b/foo \n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("foo ")); + assert_eq!(p.new.as_deref(), Some("foo ")); + } + + #[test] + fn new_path_from_single_line() { + assert_eq!( + new_path_from_header("diff --git a/foo.txt b/foo.txt"), + Some("foo.txt".to_string()) + ); + assert_eq!( + new_path_from_header("diff --git \"a/foo\\nbar\" \"b/foo\\nbar\""), + Some("foo\nbar".to_string()) + ); + } + + #[test] + fn binary_dev_null_sides() { + let added = "diff --git a/new.bin b/new.bin\nBinary files /dev/null and b/new.bin differ\n"; + let p = paths_from_diff(added).expect("paths"); + assert_eq!(p.old, None); + assert_eq!(p.new.as_deref(), Some("new.bin")); + + let deleted = + "diff --git a/old.bin b/old.bin\nBinary files a/old.bin and /dev/null differ\n"; + let p = paths_from_diff(deleted).expect("paths"); + assert_eq!(p.old.as_deref(), Some("old.bin")); + assert_eq!(p.new, None); + } +} diff --git a/src/git/mod.rs b/src/git/mod.rs index e762a45..552084d 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -3,6 +3,7 @@ pub mod bisect; pub mod branch; pub mod commit; pub mod diff; +pub(crate) mod diff_paths; pub mod file; pub mod loader; pub mod rebase; @@ -338,6 +339,21 @@ impl GitCommands { Ok(result.stdout_trimmed().to_string()) } + /// Resolve a revision to a verified commit, peeling annotated tags. + /// Unlike `resolve_ref`, this rejects trees and option-like input. + fn resolve_commit_ref(&self, reference: &str) -> Result { + let result = self + .git() + .args(&[ + "rev-parse", + "--verify", + "--end-of-options", + &format!("{reference}^{{commit}}"), + ]) + .run_expecting_success()?; + Ok(result.stdout_trimmed().to_string()) + } + /// Get the name of the previously checked-out branch (`@{-1}`), if any. pub fn previous_branch_name(&self) -> Option { let result = self @@ -444,6 +460,244 @@ mod tests { } } + fn run_test_git(path: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .current_dir(path) + .args([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "-c", + "tag.gpgsign=false", + ]) + .args(args) + .output() + .expect("run git"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() + } + + #[test] + fn load_compare_commits_linear_history_and_commit_revisions() { + use crate::model::commit::Divergence; + + let temp = TempDir::new("compare-commits-linear"); + let run = |args: &[&str]| run_test_git(temp.path(), args); + run(&["init", "-b", "main"]); + run(&["commit", "--allow-empty", "-m", "root"]); + let root = run(&["rev-parse", "HEAD"]); + run(&["tag", "-a", "base", "-m", "base"]); + run(&["checkout", "-b", "feature"]); + run(&["commit", "--allow-empty", "-m", "one | pipe"]); + let first = run(&["rev-parse", "HEAD"]); + run(&["commit", "--allow-empty", "-m", "two"]); + let tip = run(&["rev-parse", "HEAD"]); + run(&["tag", "-a", "tip", "-m", "tip"]); + let git = GitCommands::new(temp.path()).unwrap(); + + for (a, b, side) in [ + ("feature", "main", Divergence::Left), + ("base", "feature", Divergence::Right), + (root.as_str(), tip.as_str(), Divergence::Right), + ("HEAD~2", "tip", Divergence::Right), + ] { + let commits = git.load_compare_commits(a, b, 0, 0).unwrap(); + assert_eq!(commits.len(), 2); + assert_eq!(commits[0].hash, tip); + assert_eq!(commits[1].hash, first); + assert!(commits.iter().all(|commit| commit.divergence == side)); + assert_eq!(commits[1].name, "one | pipe"); + assert_eq!(commits[1].author_name, "Test"); + assert_eq!(commits[1].author_email, "test@example.com"); + assert!(commits[1].unix_timestamp > 0); + assert_eq!(commits[1].parents, [root.clone()]); + assert_eq!(commits[0].tags, ["tip"]); + assert!(commits[0].refs.iter().any(|r| r.contains("feature"))); + } + assert!( + git.load_compare_commits("feature", "tip", 10, 0) + .unwrap() + .is_empty() + ); + assert!( + git.load_compare_commits("main", "main", 10, 0) + .unwrap() + .is_empty() + ); + let commits = git.load_compare_commits("HEAD~1", "HEAD", 10, 0).unwrap(); + assert_eq!(commits.len(), 1); + assert_eq!(commits[0].hash, tip); + assert_eq!(commits[0].divergence, Divergence::Right); + } + + #[test] + fn load_compare_commits_diverged_history_and_paging() { + use crate::model::commit::Divergence; + + let temp = TempDir::new("compare-commits-diverged"); + let run = |args: &[&str]| run_test_git(temp.path(), args); + run(&["init", "-b", "main"]); + run(&["commit", "--allow-empty", "-m", "root"]); + let root = run(&["rev-parse", "HEAD"]); + run(&["checkout", "-b", "feature"]); + run(&["commit", "--allow-empty", "-m", "feature one"]); + let feature_one = run(&["rev-parse", "HEAD"]); + run(&["commit", "--allow-empty", "-m", "feature two"]); + let feature_two = run(&["rev-parse", "HEAD"]); + run(&["checkout", "main"]); + run(&["commit", "--allow-empty", "-m", "main one"]); + let main_one = run(&["rev-parse", "HEAD"]); + let git = GitCommands::new(temp.path()).unwrap(); + let commits = git.load_compare_commits("feature", "main", 0, 0).unwrap(); + assert_eq!(commits.len(), 3); + assert!(!commits.iter().any(|commit| commit.hash == root)); + for (hash, side) in [ + (&feature_one, Divergence::Left), + (&feature_two, Divergence::Left), + (&main_one, Divergence::Right), + ] { + assert_eq!( + commits.iter().find(|c| &c.hash == hash).unwrap().divergence, + side + ); + } + let position = |hash: &str| commits.iter().position(|c| c.hash == hash).unwrap(); + assert!(position(&feature_two) < position(&feature_one)); + + for skip in 0..commits.len() { + let page = git + .load_compare_commits("feature", "main", 1, skip) + .unwrap(); + assert_eq!(page.len(), 1); + assert_eq!(page[0].hash, commits[skip].hash); + assert_eq!(page[0].divergence, commits[skip].divergence); + } + assert!( + git.load_compare_commits("feature", "main", 2, 3) + .unwrap() + .is_empty() + ); + assert_eq!( + git.load_compare_commits("feature", "main", 0, 1) + .unwrap() + .len(), + 2 + ); + let reverse = git.load_compare_commits("main", "feature", 0, 0).unwrap(); + for commit in reverse { + let original = commits.iter().find(|c| c.hash == commit.hash).unwrap(); + assert_ne!(original.divergence, commit.divergence); + } + } + + #[test] + fn load_compare_commits_rejects_invalid_and_non_commit_refs() { + let temp = TempDir::new("compare-commits-invalid"); + let run = |args: &[&str]| run_test_git(temp.path(), args); + run(&["init", "-b", "main"]); + run(&["commit", "--allow-empty", "-m", "root"]); + let tree = run(&["rev-parse", "HEAD^{tree}"]); + run(&["tag", "-a", "tree-tag", &tree, "-m", "tree tag"]); + let git = GitCommands::new(temp.path()).unwrap(); + for reference in [ + "missing-ref", + "HEAD^{tree}", + tree.as_str(), + "tree-tag", + "--all", + "HEAD..HEAD", + "", + ] { + assert!( + git.load_compare_commits(reference, "main", 10, 0).is_err(), + "left: {reference}" + ); + assert!( + git.load_compare_commits("main", reference, 10, 0).is_err(), + "right: {reference}" + ); + } + } + + #[test] + fn diff_refs_ahead_behind_counts_commit_ancestry_not_file_changes() { + let temp = TempDir::new("compare-ancestry"); + let run = |args: &[&str]| { + let output = Command::new("git") + .current_dir(temp.path()) + .args([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "-c", + "tag.gpgsign=false", + ]) + .args(args) + .output() + .expect("run git"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() + }; + run(&["init", "-b", "main"]); + run(&["commit", "--allow-empty", "-m", "root"]); + let root = run(&["rev-parse", "HEAD"]); + run(&["tag", "-a", "base", "-m", "base"]); + run(&["checkout", "-b", "feature"]); + run(&["commit", "--allow-empty", "-m", "feature one"]); + run(&["commit", "--allow-empty", "-m", "feature two"]); + let tip = run(&["rev-parse", "HEAD"]); + run(&["update-ref", "refs/remotes/origin/feature", &tip]); + + let git = GitCommands::new(temp.path()).unwrap(); + assert_eq!( + git.diff_refs_ahead_behind("feature", "main").unwrap(), + (2, 0) + ); + assert_eq!( + git.diff_refs_ahead_behind("base", "feature").unwrap(), + (0, 2) + ); + assert_eq!(git.diff_refs_ahead_behind(&tip, &root).unwrap(), (2, 0)); + assert_eq!( + git.diff_refs_ahead_behind("HEAD~1", "HEAD").unwrap(), + (0, 1) + ); + assert_eq!( + git.diff_refs_ahead_behind("origin/feature", "feature") + .unwrap(), + (0, 0) + ); + assert!(git.diff_refs_files("base", "feature").unwrap().is_empty()); + + run(&["checkout", "main"]); + run(&["commit", "--allow-empty", "-m", "main one"]); + assert_eq!( + git.diff_refs_ahead_behind("feature", "main").unwrap(), + (2, 1) + ); + assert_eq!( + git.diff_refs_ahead_behind("main", "feature").unwrap(), + (1, 2) + ); + assert!(git.diff_refs_ahead_behind("missing-ref", "main").is_err()); + assert!(git.diff_refs_ahead_behind("HEAD^{tree}", "main").is_err()); + assert!(git.diff_refs_files("HEAD^{tree}", "main").is_ok()); + } + #[test] fn new_resolves_repo_path_to_worktree_root_from_subdirectory() { let temp = TempDir::new("repo-root"); diff --git a/src/gui/controller/commit_files.rs b/src/gui/controller/commit_files.rs index 6a7bda7..bb0d35c 100644 --- a/src/gui/controller/commit_files.rs +++ b/src/gui/controller/commit_files.rs @@ -47,24 +47,27 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) return Ok(()); } - // Enter: toggle directory collapse in tree view, or focus diff for files - if key.code == KeyCode::Enter { - if gui.show_commit_file_tree { - let selected = gui.context_mgr.selected_active(); - if let Some(node) = gui.commit_file_tree_nodes.get(selected) { - if node.is_dir { - let path = node.path.clone(); - if gui.commit_files_collapsed_dirs.contains(&path) { - gui.commit_files_collapsed_dirs.remove(&path); - } else { - gui.commit_files_collapsed_dirs.insert(path); - } - update_commit_file_tree_state(gui); - return Ok(()); + // Folding is explicit; Enter focuses file or combined directory diffs. + if gui.show_commit_file_tree + && super::tree::matches_key(key, &keybindings.universal.fold_directory) + { + if let Some(node) = gui + .commit_file_tree_nodes + .get(gui.context_mgr.selected_active()) + { + if node.is_dir { + let path = node.path.clone(); + if !gui.commit_files_collapsed_dirs.remove(&path) { + gui.commit_files_collapsed_dirs.insert(path); } + update_commit_file_tree_state(gui); + gui.context_mgr.viewport_manually_scrolled = false; + gui.needs_diff_refresh = true; } } - // Focus the diff panel for the selected file + return Ok(()); + } + if key.code == KeyCode::Enter { if !gui.diff_view.is_empty() { gui.diff_focused = true; } diff --git a/src/gui/controller/commits.rs b/src/gui/controller/commits.rs index 7f4b93e..b4f8f41 100644 --- a/src/gui/controller/commits.rs +++ b/src/gui/controller/commits.rs @@ -16,13 +16,24 @@ use crate::model::Branch; use crate::os::platform::Platform; pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) -> Result<()> { - // Esc: cancel range select first, then clear clipboard + // Esc: cancel range select first, then clear active commit filter, then clear clipboard if key.code == crossterm::event::KeyCode::Esc { if gui.range_select_anchor.is_some() { gui.range_select_anchor = None; return Ok(()); } + if gui.commit_path_filter.is_some() + || !gui.commit_author_filter.is_empty() + || !gui.commit_branch_filter.is_empty() + { + gui.commit_path_filter = None; + gui.commit_author_filter.clear(); + gui.commit_branch_filter.clear(); + apply_commit_filters_and_focus(gui)?; + return Ok(()); + } + if !gui.cherry_pick_clipboard.is_empty() { gui.cherry_pick_clipboard.clear(); return Ok(()); @@ -133,7 +144,11 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) } // Open commit filtering menu - if matches_key(key, &keybindings.commits.open_log_menu) { + if matches_key(key, &keybindings.commits.open_log_menu) + || (key.modifiers == crossterm::event::KeyModifiers::CONTROL + && (key.code == crossterm::event::KeyCode::Char('l') + || key.code == crossterm::event::KeyCode::Char('L'))) + { return show_filtering_menu(gui); } @@ -1324,26 +1339,40 @@ fn apply_commit_filters(gui: &mut Gui) -> Result<()> { Ok(()) } -fn show_branch_filter_menu(gui: &mut Gui) -> Result<()> { - use crate::gui::popup::ChecklistItem; +fn build_branch_checklist_items( + branches: &[Branch], + current_filter: &[String], +) -> Vec { + let mut items = vec![ChecklistItem { + label: "".to_string(), + checked: false, + is_free_entry: false, + }]; + + for b in branches { + let checked = current_filter.contains(&b.name); + items.push(ChecklistItem { + label: b.name.clone(), + checked, + is_free_entry: false, + }); + } - let model = gui.model.lock().unwrap(); - let branches: Vec = model.branches.iter().map(|b| b.name.clone()).collect(); - drop(model); + items +} - let current_filter = &gui.commit_branch_filter; +fn resolve_branch_filter_selection(checked: Vec) -> Vec { + if checked.iter().any(|val| val == "") { + Vec::new() + } else { + checked + } +} - let items: Vec = branches - .into_iter() - .map(|name| { - let checked = current_filter.contains(&name); - ChecklistItem { - label: name, - checked, - is_free_entry: false, - } - }) - .collect(); +fn show_branch_filter_menu(gui: &mut Gui) -> Result<()> { + let model = gui.model.lock().unwrap(); + let items = build_branch_checklist_items(&model.branches, &gui.commit_branch_filter); + drop(model); gui.popup = PopupState::Checklist { title: "Filter commits by branch".to_string(), @@ -1351,7 +1380,7 @@ fn show_branch_filter_menu(gui: &mut Gui) -> Result<()> { selected: 0, search_textarea: crate::gui::popup::make_checklist_search_textarea(), on_confirm: Box::new(|gui: &mut Gui, checked: Vec| { - gui.commit_branch_filter = checked; + gui.commit_branch_filter = resolve_branch_filter_selection(checked); apply_commit_filters_and_focus(gui) }), free_entry_category: None, @@ -1551,4 +1580,48 @@ mod tests { fn treats_blank_filter_values_as_unset() { assert_eq!(nonempty(" "), None); } + + #[test] + fn build_branch_checklist_items_starts_with_unchecked_clear_filter() { + use super::build_branch_checklist_items; + + let branches = vec![branch("main", "1111111"), branch("feature", "2222222")]; + let current_filter = vec!["main".to_string()]; + let items = build_branch_checklist_items(&branches, ¤t_filter); + + assert_eq!(items.len(), 3); + assert_eq!(items[0].label, ""); + assert!(!items[0].checked); + + assert_eq!(items[1].label, "main"); + assert!(items[1].checked); + + assert_eq!(items[2].label, "feature"); + assert!(!items[2].checked); + } + + #[test] + fn resolve_branch_filter_selection_clears_when_clear_filter_selected() { + use super::resolve_branch_filter_selection; + + let checked = vec!["".to_string(), "main".to_string()]; + assert!(resolve_branch_filter_selection(checked).is_empty()); + + let checked = vec!["".to_string()]; + assert!(resolve_branch_filter_selection(checked).is_empty()); + } + + #[test] + fn resolve_branch_filter_selection_preserves_selected_branches() { + use super::resolve_branch_filter_selection; + + let checked = vec!["main".to_string(), "feature".to_string()]; + assert_eq!( + resolve_branch_filter_selection(checked), + vec!["main".to_string(), "feature".to_string()] + ); + + let checked: Vec = Vec::new(); + assert!(resolve_branch_filter_selection(checked).is_empty()); + } } diff --git a/src/gui/controller/diff_grep.rs b/src/gui/controller/diff_grep.rs index f6aebfe..3b45e0b 100644 --- a/src/gui/controller/diff_grep.rs +++ b/src/gui/controller/diff_grep.rs @@ -65,13 +65,21 @@ pub fn open_diff_grep_picker(gui: &mut Gui) -> Result<()> { }; return Ok(()); } - let ref_a = gui.diff_mode.ref_a.clone(); - let ref_b = gui.diff_mode.ref_b.clone(); - diff_text = gui - .git - .diff_refs_paths(&ref_a, &ref_b, &[]) - .unwrap_or_default(); - scope_title = format!("Grep {ref_a}..{ref_b}"); + if gui.diff_mode.diff_source == crate::gui::modes::diff_mode::CompareDiffSource::Commit { + super::diff_mode::open_selected_commit_files(gui)?; + } + if let Some(hash) = &gui.diff_mode.files_commit { + diff_text = gui.git.diff_commit(hash).unwrap_or_default(); + scope_title = format!("Grep {}", &hash[..8.min(hash.len())]); + } else { + let ref_a = gui.diff_mode.ref_a.clone(); + let ref_b = gui.diff_mode.ref_b.clone(); + diff_text = gui + .git + .diff_refs_paths(&ref_a, &ref_b, &[]) + .unwrap_or_default(); + scope_title = format!("Grep {ref_a}..{ref_b}"); + } scope = DiffGrepScope::Compare; } else { match gui.context_mgr.active() { @@ -175,6 +183,8 @@ fn confirm_diff_grep(gui: &mut Gui, scope: DiffGrepScope, value: &str) -> Result .position(|f| f.current_path() == path || f.name == path) }; let Some(idx) = idx else { return Ok(()) }; + gui.diff_mode + .set_focus(crate::gui::modes::diff_mode::DiffModeFocus::CommitFiles); if gui.show_file_tree { expand_ancestors(&mut gui.collapsed_dirs, &path); gui.update_file_tree_state(); diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index 73d4511..c943d9b 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -5,13 +5,105 @@ use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::config::keybindings::parse_key; -use crate::gui::modes::diff_mode::{DiffModeFocus, DiffModeSelector}; +use crate::gui::modes::diff_mode::{CompareDiffSource, DiffModeFocus, DiffModeSelector}; use crate::gui::popup::{CommandEntry, CommandSection, MenuItem, PopupState}; use crate::gui::{DiffPayload, Gui, textarea_input}; use crate::model::FileChangeStatus; use crate::os::platform::Platform; use crate::pager::side_by_side::{DiffPanelLayout, DiffViewState}; +fn handle_commits_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { + let selected = gui.diff_mode.commits_selected; + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if selected + 1 >= gui.diff_mode.commits.len() { + load_more_commits(gui)?; + } + gui.diff_mode.commits_selected = + (selected + 1).min(gui.diff_mode.commits.len().saturating_sub(1)); + } + KeyCode::Char('k') | KeyCode::Up => { + gui.diff_mode.commits_selected = selected.saturating_sub(1) + } + KeyCode::PageDown => { + if selected + 10 >= gui.diff_mode.commits.len() { + load_more_commits(gui)?; + } + gui.diff_mode.commits_selected = + (selected + 10).min(gui.diff_mode.commits.len().saturating_sub(1)); + } + KeyCode::PageUp => gui.diff_mode.commits_selected = selected.saturating_sub(10), + KeyCode::Char('g') | KeyCode::Home => gui.diff_mode.commits_selected = 0, + KeyCode::Char('G') | KeyCode::End => { + gui.diff_mode.commits_selected = gui.diff_mode.commits.len().saturating_sub(1); + } + KeyCode::Enter => return open_selected_commit_files(gui), + KeyCode::Char('y') => { + if let Some(commit) = gui.diff_mode.selected_commit() { + Platform::copy_to_clipboard(&commit.hash)?; + } + } + _ => return Ok(()), + } + gui.diff_mode.commits_viewport_manually_scrolled = false; + gui.needs_diff_refresh = true; + Ok(()) +} + +pub fn load_more_commits(gui: &mut Gui) -> Result<()> { + if !gui.diff_mode.has_more_commits() { + return Ok(()); + } + let commits = gui.git.load_compare_commits( + &gui.diff_mode.ref_a, + &gui.diff_mode.ref_b, + crate::git::DEFAULT_COMMIT_LIMIT, + gui.diff_mode.commits.len(), + )?; + gui.diff_mode.commits.extend(commits); + gui.diff_mode.commits_revision = gui.diff_mode.commits_revision.wrapping_add(1); + Ok(()) +} + +pub fn open_selected_commit_files(gui: &mut Gui) -> Result<()> { + let Some(hash) = gui + .diff_mode + .selected_commit() + .map(|commit| commit.hash.clone()) + else { + return Ok(()); + }; + let files = gui.git.commit_files(&hash)?; + set_diff_files(gui, files); + gui.diff_mode.files_commit = Some(hash); + gui.diff_mode.set_focus(DiffModeFocus::CommitFiles); + gui.clear_diff_view(); + gui.needs_diff_refresh = true; + gui.needs_diff_refresh = true; + Ok(()) +} + +pub fn restore_comparison_files(gui: &mut Gui) -> Result<()> { + let files = gui + .git + .diff_refs_files(&gui.diff_mode.ref_a, &gui.diff_mode.ref_b)?; + set_diff_files(gui, files); + gui.diff_mode.files_commit = None; + gui.diff_mode.set_focus(DiffModeFocus::CommitFiles); + gui.clear_diff_view(); + Ok(()) +} + +fn set_diff_files(gui: &mut Gui, files: Vec) { + gui.diff_mode.diff_files = files; + gui.diff_mode.diff_files_selected = 0; + gui.diff_mode.diff_files_scroll = 0; + gui.diff_mode.viewport_manually_scrolled = false; + gui.diff_mode.collapsed_dirs.clear(); + gui.diff_mode.clear_list_search(); + update_diff_mode_tree(gui); +} + pub fn handle_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { // Popup takes priority (for ? help) if gui.popup != PopupState::None { @@ -45,6 +137,13 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { return Ok(()); } + // Keep text-entry shortcuts local, including diff-content search. + if gui.diff_mode.focus == DiffModeFocus::DiffExploration && gui.diff_view.search_active { + return handle_diff_search_key(gui, key); + } + if gui.try_handle_layout_key(key) { + return Ok(()); + } let keybindings = &gui.config.user_config.keybinding; if matches_key(key, &keybindings.universal.toggle_diff_view_layout) { @@ -91,17 +190,31 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { } } - // Tab to cycle focus - if key.code == KeyCode::Tab { - gui.diff_mode.focus = gui.diff_mode.focus.next(); + // Use the same configurable forward/reverse bindings as the main view. + let mut panel_key = key; + if panel_key.code == KeyCode::BackTab + || (panel_key.code == KeyCode::Tab && panel_key.modifiers.contains(KeyModifiers::SHIFT)) + { + panel_key.code = KeyCode::BackTab; + panel_key.modifiers.insert(KeyModifiers::SHIFT); + } + let forward = matches_key(panel_key, &keybindings.universal.toggle_panel); + let reverse = matches_key(panel_key, &keybindings.universal.toggle_panel_reverse); + if forward || reverse { + let focus = if reverse { + gui.diff_mode.focus.prev() + } else { + gui.diff_mode.focus.next() + }; + gui.diff_mode.set_focus(focus); gui.needs_diff_refresh = true; return Ok(()); } - // Number keys 1-4 to jump to focus panel - if let KeyCode::Char(c @ '1'..='4') = key.code { + // Number keys 1-5 to jump to focus panel + if let KeyCode::Char(c @ '1'..='5') = key.code { if let Some(focus) = DiffModeFocus::from_number(c.to_digit(10).unwrap()) { - gui.diff_mode.focus = focus; + gui.diff_mode.set_focus(focus); gui.needs_diff_refresh = true; return Ok(()); } @@ -148,6 +261,9 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { DiffModeFocus::CommitFiles => { handle_commit_files_key(gui, key)?; } + DiffModeFocus::Commits => { + handle_commits_key(gui, key)?; + } DiffModeFocus::DiffExploration => { handle_diff_exploration_key(gui, key)?; } @@ -166,10 +282,10 @@ fn handle_combobox_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { if gui.diff_mode.has_both_refs() { reload_diff_files(gui)?; // Both refs set — auto-focus commit files - gui.diff_mode.focus = DiffModeFocus::CommitFiles; + gui.diff_mode.set_focus(DiffModeFocus::CommitFiles); } else if gui.diff_mode.ref_a.is_empty() { // B was just set, A still empty — jump to A and start editing - gui.diff_mode.focus = DiffModeFocus::SelectorA; + gui.diff_mode.set_focus(DiffModeFocus::SelectorA); gui.diff_mode.start_editing(DiffModeSelector::A); let model = gui.model.lock().unwrap(); gui.diff_mode.search_refs( @@ -181,7 +297,7 @@ fn handle_combobox_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { ); } else { // A was just set, B still empty — jump to B and start editing - gui.diff_mode.focus = DiffModeFocus::SelectorB; + gui.diff_mode.set_focus(DiffModeFocus::SelectorB); gui.diff_mode.start_editing(DiffModeSelector::B); let model = gui.model.lock().unwrap(); gui.diff_mode.search_refs( @@ -241,7 +357,8 @@ fn handle_file_search_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { // Jump to first match if !gui.diff_mode.file_search_matches.is_empty() { gui.diff_mode.file_search_match_idx = 0; - gui.diff_mode.diff_files_selected = gui.diff_mode.file_search_matches[0]; + gui.diff_mode + .select_list_match(gui.diff_mode.file_search_matches[0]); } gui.diff_mode.file_search_textarea = None; gui.needs_diff_refresh = true; @@ -257,7 +374,32 @@ fn handle_file_search_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { Ok(()) } +fn handle_tree_navigation(gui: &mut Gui, key: KeyEvent) -> bool { + if !gui.diff_mode.show_tree { + return false; + } + if let Some(destination) = super::tree::destination( + key, + &gui.config.user_config.keybinding, + &gui.diff_mode.tree_nodes, + gui.diff_mode.diff_files_selected, + ) { + if let Some(idx) = destination { + gui.diff_mode.diff_files_selected = idx; + gui.diff_mode.viewport_manually_scrolled = false; + gui.needs_diff_refresh = true; + } + return true; + } + false +} + fn handle_commit_files_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { + if key.code == KeyCode::Esc && gui.diff_mode.files_commit.is_some() { + restore_comparison_files(gui)?; + gui.needs_diff_refresh = true; + return Ok(()); + } let keybindings = &gui.config.user_config.keybinding; // Toggle tree view (backtick) — keep in sync with Files / Commit Files and persist @@ -272,6 +414,30 @@ fn handle_commit_files_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { return Ok(()); } + if gui.diff_mode.show_tree + && super::tree::matches_key(key, &keybindings.universal.fold_directory) + { + if let Some(node) = gui + .diff_mode + .tree_nodes + .get(gui.diff_mode.diff_files_selected) + { + if node.is_dir { + let path = node.path.clone(); + if !gui.diff_mode.collapsed_dirs.remove(&path) { + gui.diff_mode.collapsed_dirs.insert(path); + } + update_diff_mode_tree(gui); + gui.diff_mode.viewport_manually_scrolled = false; + gui.needs_diff_refresh = true; + } + } + return Ok(()); + } + if handle_tree_navigation(gui, key) { + return Ok(()); + } + let len = gui.diff_mode.visible_files_len(); if len == 0 { return Ok(()); @@ -293,26 +459,7 @@ fn handle_commit_files_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { } } KeyCode::Enter => { - if gui.diff_mode.show_tree { - // Toggle dir collapse or focus diff - if let Some(node) = gui - .diff_mode - .tree_nodes - .get(gui.diff_mode.diff_files_selected) - { - if node.is_dir { - let path = node.path.clone(); - if gui.diff_mode.collapsed_dirs.contains(&path) { - gui.diff_mode.collapsed_dirs.remove(&path); - } else { - gui.diff_mode.collapsed_dirs.insert(path); - } - update_diff_mode_tree(gui); - return Ok(()); - } - } - } - gui.diff_mode.focus = DiffModeFocus::DiffExploration; + gui.diff_mode.set_focus(DiffModeFocus::DiffExploration); gui.needs_diff_refresh = true; } KeyCode::Char('g') => { @@ -356,8 +503,11 @@ fn show_commit_file_copy_menu(gui: &mut Gui) -> Result<()> { .map_or_else(|| file.name.clone(), |(old, _)| old.to_string()); let new_path = file.current_path().to_string(); let status = file.status; - let ref_a = gui.diff_mode.ref_a.clone(); - let ref_b = gui.diff_mode.ref_b.clone(); + let (ref_a, ref_b) = match &gui.diff_mode.files_commit { + Some(hash) => (format!("{hash}^1"), hash.clone()), + None => (gui.diff_mode.ref_a.clone(), gui.diff_mode.ref_b.clone()), + }; + let commit_for_diff = gui.diff_mode.files_commit.clone(); let path_for_old = old_path.clone(); let path_for_new = new_path.clone(); let path_for_diff = file_name.clone(); @@ -384,7 +534,12 @@ fn show_commit_file_copy_menu(gui: &mut Gui) -> Result<()> { })), }, MenuItem { - label: "Old content (from A)".to_string(), + label: if commit_for_diff.is_some() { + "Old content (parent)" + } else { + "Old content (from A)" + } + .to_string(), description: if has_old { String::new() } else { @@ -404,7 +559,12 @@ fn show_commit_file_copy_menu(gui: &mut Gui) -> Result<()> { }, }, MenuItem { - label: "New content (from B)".to_string(), + label: if commit_for_diff.is_some() { + "New content (commit)" + } else { + "New content (from B)" + } + .to_string(), description: if has_new { String::new() } else { @@ -428,9 +588,12 @@ fn show_commit_file_copy_menu(gui: &mut Gui) -> Result<()> { description: String::new(), key: Some("d".to_string()), action: Some(Box::new(move |gui| { - let diff = + let diff = if let Some(hash) = &commit_for_diff { + gui.git.diff_commit_file(hash, &path_for_diff)? + } else { gui.git - .diff_refs_file(&ref_a_for_diff, &ref_b_for_diff, &path_for_diff)?; + .diff_refs_file(&ref_a_for_diff, &ref_b_for_diff, &path_for_diff)? + }; Platform::copy_to_clipboard(&diff)?; Ok(()) })), @@ -488,22 +651,7 @@ fn handle_diff_exploration_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { let line = sel_ref.edit_line_number; // Compute column from terminal position using the same layout as the mouse handler let (top_row, top_col, _, _) = sel_ref.normalized(); - let area = ratatui::layout::Rect::new(0, 0, gui.layout.width, gui.layout.height); - let outer = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - ratatui::layout::Constraint::Min(1), - ratatui::layout::Constraint::Length(1), - ]) - .split(area); - let content = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Horizontal) - .constraints([ - ratatui::layout::Constraint::Percentage(33), - ratatui::layout::Constraint::Percentage(67), - ]) - .split(outer[0]); - let diff_rect = content[1]; + let diff_rect = gui.compute_compare_layout().diff; let pl = DiffPanelLayout::compute(diff_rect, &gui.diff_view); let (content_start, _) = pl.content_range(sel_ref.panel); let column = if top_col >= content_start { @@ -518,7 +666,7 @@ fn handle_diff_exploration_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { .map(|(line_idx, _, panel)| (line_idx, panel)) .unwrap_or_else(|| { ( - gui.diff_view.scroll_offset + (top_row - pl.inner_y) as usize, + gui.diff_view.fallback_line_idx_for_row(top_row, &pl), sel_ref.panel, ) }) @@ -583,12 +731,22 @@ fn handle_diff_exploration_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { } } + if handle_tree_navigation(gui, key) { + return Ok(()); + } + match key.code { KeyCode::Esc => { if !gui.diff_view.search_query.is_empty() { gui.diff_view.clear_search(); } else { - gui.diff_mode.focus = DiffModeFocus::CommitFiles; + gui.diff_mode.set_focus( + if gui.diff_mode.diff_source == CompareDiffSource::Commit { + DiffModeFocus::Commits + } else { + DiffModeFocus::CommitFiles + }, + ); } } KeyCode::Char('j') | KeyCode::Down => { @@ -650,6 +808,8 @@ fn handle_diff_exploration_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { pub fn reload_diff_files(gui: &mut Gui) -> Result<()> { let ref_a = gui.diff_mode.ref_a.clone(); let ref_b = gui.diff_mode.ref_b.clone(); + gui.diff_mode.ahead_behind = None; + gui.diff_mode.clear_commits(); if ref_a.is_empty() || ref_b.is_empty() { return Ok(()); } @@ -658,15 +818,20 @@ pub fn reload_diff_files(gui: &mut Gui) -> Result<()> { match gui.git.diff_refs_files(&ref_a, &ref_b) { Ok(files) => { - gui.diff_mode.diff_files = files; - gui.diff_mode.diff_files_selected = 0; - gui.diff_mode.diff_files_scroll = 0; - if gui.diff_mode.show_tree { - update_diff_mode_tree(gui); + // Tree refs can still be diffed even though they have no commit history. + gui.diff_mode.ahead_behind = gui.git.diff_refs_ahead_behind(&ref_a, &ref_b).ok(); + if gui.diff_mode.ahead_behind.is_some() { + gui.diff_mode.commits = gui + .git + .load_compare_commits(&ref_a, &ref_b, crate::git::DEFAULT_COMMIT_LIMIT, 0) + .unwrap_or_default(); + gui.diff_mode.commits_revision = gui.diff_mode.commits_revision.wrapping_add(1); } + set_diff_files(gui, files); } Err(e) => { gui.diff_mode.diff_files.clear(); + gui.diff_mode.tree_nodes.clear(); gui.popup = PopupState::Message { title: "Diff error".to_string(), message: format!("{}", e), @@ -674,6 +839,7 @@ pub fn reload_diff_files(gui: &mut Gui) -> Result<()> { }; } } + gui.diff_mode.set_focus(gui.diff_mode.focus); Ok(()) } @@ -691,6 +857,33 @@ fn update_diff_mode_tree(gui: &mut Gui) { /// Called from the main loop to request diff loading for the currently selected file in diff mode. /// Queues the request on the shared latest-only diff worker. pub fn maybe_request_diff(gui: &mut Gui, generation: u64, diff_key: String) { + if gui.diff_mode.diff_source == CompareDiffSource::Commit { + let Some(hash) = gui + .diff_mode + .selected_commit() + .map(|commit| commit.hash.clone()) + else { + gui.diff_loading = false; + gui.diff_loading_since = None; + gui.clear_diff_view(); + return; + }; + let git = Arc::clone(&gui.git); + gui.queue_diff_job(generation, diff_key, move || { + let diff = git.diff_commit(&hash).unwrap_or_default(); + if diff.is_empty() { + DiffPayload::Empty + } else { + DiffPayload::Parsed(DiffViewState::parse_diff_output( + &hash[..8.min(hash.len())], + &diff, + 4, + false, + )) + } + }); + return; + } if !gui.diff_mode.has_both_refs() || gui.diff_mode.diff_files.is_empty() { gui.diff_loading = false; gui.diff_loading_since = None; @@ -701,6 +894,8 @@ pub fn maybe_request_diff(gui: &mut Gui, generation: u64, diff_key: String) { let ref_a = gui.diff_mode.ref_a.clone(); let ref_b = gui.diff_mode.ref_b.clone(); + let files_commit = gui.diff_mode.files_commit.clone(); + // Resolve file index (tree view maps node -> file index) let selected = gui.diff_mode.diff_files_selected; let file_idx = if gui.diff_mode.show_tree { @@ -727,13 +922,39 @@ pub fn maybe_request_diff(gui: &mut Gui, generation: u64, diff_key: String) { let current_path = file.current_path().to_string(); gui.queue_diff_job(generation, diff_key, move || { - match git.diff_refs_file(&ref_a, &ref_b, &name) { + let result = if let Some(hash) = &files_commit { + git.diff_commit_file(hash, &name) + } else { + git.diff_refs_file(&ref_a, &ref_b, &name) + }; + match result { Ok(diff) if diff.is_empty() => DiffPayload::Empty, Ok(diff) => { let exists = git.repo_path().join(¤t_path).exists(); + use crate::pager::image_preview::ImageSource; + let paths = crate::git::diff::diff_paths_for_label(&name); + if let Some(parsed) = super::super::image_diff_payload( + &git, + &name, + &diff, + exists, + ImageSource::Revision { + revision: &ref_a, + path: paths[0], + }, + ImageSource::Revision { + revision: &ref_b, + path: ¤t_path, + }, + ) { + return DiffPayload::Parsed(parsed); + } // Pure renames between refs: show file content at ref_b. if crate::pager::side_by_side::is_rename_only_diff(&diff) { - if let Ok(content) = git.file_content_at_commit(&ref_b, ¤t_path) { + if let Ok(content) = git.file_content_at_commit( + files_commit.as_deref().unwrap_or(&ref_b), + ¤t_path, + ) { if !content.is_empty() { return DiffPayload::Parsed(DiffViewState::parse_content( ¤t_path, @@ -772,18 +993,26 @@ pub fn maybe_request_diff(gui: &mut Gui, generation: u64, diff_key: String) { Some(p) => vec![p], None => Vec::new(), }; - let combined_diff = git - .diff_refs_paths(&ref_a, &ref_b, &paths) - .unwrap_or_default(); + let combined_diff = if let Some(hash) = &files_commit { + git.diff_commit_paths(hash, &paths) + } else { + git.diff_refs_paths(&ref_a, &ref_b, &paths) + } + .unwrap_or_default(); if combined_diff.is_empty() { DiffPayload::Empty } else { - DiffPayload::Parsed(DiffViewState::parse_diff_output( - &dir_name, + let mut parsed = + DiffViewState::parse_diff_output(&dir_name, &combined_diff, 4, true); + crate::pager::image_preview::attach_inline_image_previews( + &mut parsed, + git.repo_path(), &combined_diff, - 4, - true, - )) + &ref_a, + Some(&ref_b), + &std::collections::HashSet::new(), + ); + DiffPayload::Parsed(parsed) } }); } else { @@ -807,12 +1036,83 @@ fn show_diff_mode_command_palette(gui: &mut Gui) { title: "Compare / Diff Mode".into(), entries: vec![ CommandEntry::keybinding("q".into(), "Exit diff mode".into()), - CommandEntry::keybinding("Tab".into(), "Cycle focus (A → B → Files → Diff)".into()), - CommandEntry::keybinding("1-4".into(), "Jump to panel".into()), + CommandEntry::keybinding( + gui.config + .user_config + .keybinding + .universal + .toggle_panel + .clone(), + "Cycle focus (A → B → Files → Commits → Diff)".into(), + ), + CommandEntry::keybinding( + gui.config + .user_config + .keybinding + .universal + .toggle_panel_reverse + .clone(), + "Cycle focus backward".into(), + ), + CommandEntry::keybinding( + gui.config + .user_config + .keybinding + .universal + .next_screen_mode + .clone(), + "Next screen mode (normal / half / full)".into(), + ), + CommandEntry::keybinding( + gui.config + .user_config + .keybinding + .universal + .prev_screen_mode + .clone(), + "Previous screen mode".into(), + ), + CommandEntry::keybinding( + format!( + "{} / {}", + gui.config + .user_config + .keybinding + .universal + .shrink_side_panel, + gui.config + .user_config + .keybinding + .universal + .expand_side_panel + ), + "Shrink / expand sidebar (or drag its divider)".into(), + ), + CommandEntry::keybinding( + gui.config + .user_config + .keybinding + .universal + .reset_side_panel + .clone(), + "Reset sidebar size".into(), + ), + CommandEntry::keybinding("1-5".into(), "Jump to panel (4 Commits, 5 Diff)".into()), CommandEntry::keybinding("".into(), "Swap A and B".into()), - CommandEntry::keybinding("".into(), "Edit selector / Focus diff".into()), + CommandEntry::keybinding( + "".into(), + "Edit ref / Open commit files / Focus diff".into(), + ), + CommandEntry::keybinding( + "".into(), + "Back from diff / Restore comparison files".into(), + ), CommandEntry::keybinding("`".into(), "Toggle file tree view".into()), - CommandEntry::keybinding("j/k".into(), "Navigate files / Scroll diff".into()), + CommandEntry::keybinding( + "j/k".into(), + "Navigate files or commits / Scroll diff".into(), + ), + CommandEntry::keybinding("PgUp/PgDn".into(), "Page through commits".into()), CommandEntry::keybinding("{/}".into(), "Previous / next hunk".into()), CommandEntry::keybinding("[/]".into(), "Toggle old / new only view".into()), CommandEntry::keybinding( @@ -826,7 +1126,10 @@ fn show_diff_mode_command_palette(gui: &mut Gui) { ), CommandEntry::keybinding("z".into(), "Toggle line wrap".into()), CommandEntry::keybinding("g/G".into(), "Go to top / bottom".into()), - CommandEntry::keybinding("/".into(), "Search (files or diff content)".into()), + CommandEntry::keybinding( + "/".into(), + "Search files, loaded commits, or diff content".into(), + ), CommandEntry::keybinding("n/N".into(), "Next / previous search match".into()), CommandEntry::keybinding("".into(), "Grep diff contents".into()), CommandEntry::keybinding("y".into(), "Copy to clipboard".into()), @@ -847,8 +1150,23 @@ fn show_diff_mode_command_palette(gui: &mut Gui) { ], }; + let mut sections = vec![diff_mode_section, combobox_section]; + if gui.diff_mode.show_tree + && matches!( + gui.diff_mode.focus, + DiffModeFocus::CommitFiles | DiffModeFocus::DiffExploration + ) + { + sections.insert( + 1, + super::tree::command_section( + &gui.config.user_config.keybinding, + gui.diff_mode.focus == DiffModeFocus::CommitFiles, + ), + ); + } gui.popup = PopupState::CommandPalette { - sections: vec![diff_mode_section, combobox_section], + sections, selected: 0, search_textarea: crate::gui::popup::make_command_palette_search_textarea(), scroll_offset: 0, @@ -862,3 +1180,335 @@ fn matches_key(key: KeyEvent, binding: &str) -> bool { false } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{AppConfig, AppState, UserConfig}; + use crate::git::GitCommands; + use std::path::PathBuf; + use std::process::Command; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + struct TempRepo(PathBuf); + impl Drop for TempRepo { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn compare_controller_previews_commits_opens_files_and_routes_mouse() { + let path = std::env::temp_dir().join(format!( + "lazygitrs-compare-viewer-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + let repo = TempRepo(path); + let run = |args: &[&str]| { + let output = Command::new("git") + .current_dir(&repo.0) + .args([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + run(&["init", "-b", "main"]); + std::fs::write(repo.0.join("file.txt"), "base\n").unwrap(); + run(&["add", "file.txt"]); + run(&["commit", "-m", "root"]); + run(&["checkout", "-b", "feature"]); + std::fs::write(repo.0.join("file.txt"), "feature\n").unwrap(); + run(&["add", "file.txt"]); + run(&["commit", "-m", "feature change"]); + std::fs::write(repo.0.join("file.txt"), "base\n").unwrap(); + run(&["add", "file.txt"]); + run(&["commit", "-m", "restore content"]); + let config = AppConfig { + debug: false, + version: String::new(), + user_config: UserConfig::default(), + app_state: AppState::default(), + config_dir: repo.0.clone(), + state_dir: repo.0.clone(), + state_path: repo.0.join("state.yml"), + }; + let mut gui = Gui::new(config, GitCommands::new(&repo.0).unwrap(), None, false).unwrap(); + gui.diff_mode.enter(false); + gui.diff_mode.ref_a = "main".into(); + gui.diff_mode.ref_b = "feature".into(); + reload_diff_files(&mut gui).unwrap(); + assert_eq!(gui.diff_mode.commits.len(), 2); + assert!(gui.diff_mode.diff_files.is_empty()); + gui.diff_mode.commits.truncate(1); + load_more_commits(&mut gui).unwrap(); + assert_eq!(gui.diff_mode.commits.len(), 2); + assert!(!gui.diff_mode.has_more_commits()); + let press = + |gui: &mut Gui, key| handle_key(gui, KeyEvent::new(key, KeyModifiers::NONE)).unwrap(); + press(&mut gui, KeyCode::Char('4')); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::Commits); + let commit_key = gui.current_diff_key(); + maybe_request_diff(&mut gui, 0, commit_key.clone()); + let result = gui.diff_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(result.diff_key, commit_key); + assert!(matches!(result.payload, DiffPayload::Parsed(_))); + press(&mut gui, KeyCode::Char('5')); + assert_eq!(gui.current_diff_key(), commit_key); + press(&mut gui, KeyCode::Esc); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::Commits); + press(&mut gui, KeyCode::Enter); + assert_eq!(gui.diff_mode.diff_source, CompareDiffSource::CommitFiles); + assert_eq!(gui.diff_mode.diff_files.len(), 1); + let file_key = gui.current_diff_key(); + maybe_request_diff(&mut gui, 0, file_key.clone()); + let result = gui.diff_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(result.diff_key, file_key); + assert!(matches!(result.payload, DiffPayload::Parsed(_))); + press(&mut gui, KeyCode::Esc); + assert_eq!(gui.diff_mode.diff_source, CompareDiffSource::Comparison); + assert!(gui.diff_mode.diff_files.is_empty()); + + press(&mut gui, KeyCode::Char('4')); + handle_key( + &mut gui, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), + ) + .unwrap(); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::Commits); + assert_eq!(gui.diff_mode.diff_source, CompareDiffSource::Commit); + assert_eq!(gui.diff_mode.ahead_behind, Some((2, 0))); + + gui.layout.update_size(150, 24); + let rect = gui.compute_compare_layout().sidebar[3]; + gui.handle_diff_mode_mouse(crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left), + column: rect.x + 1, + row: rect.y + 2, + modifiers: KeyModifiers::NONE, + }); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::Commits); + assert_eq!(gui.diff_mode.commits_selected, 1); + assert_eq!(gui.diff_mode.diff_source, CompareDiffSource::Commit); + let commit = gui.diff_mode.commits[0].clone(); + gui.diff_mode.commits = vec![commit; 30]; + gui.diff_mode.ahead_behind = Some((30, 0)); + for kind in [ + crossterm::event::MouseEventKind::ScrollDown, + crossterm::event::MouseEventKind::ScrollUp, + ] { + gui.handle_diff_mode_mouse(crossterm::event::MouseEvent { + kind, + column: rect.x + 1, + row: rect.y + 2, + modifiers: KeyModifiers::NONE, + }); + assert_eq!(gui.diff_mode.commits_selected, 1); + assert_eq!( + gui.diff_mode.commits_scroll, + if kind == crossterm::event::MouseEventKind::ScrollDown { + 3 + } else { + 0 + } + ); + } + // Exercise the real GUI dispatch path, including normalized terminal + // events, custom bindings, input priority and divider dragging. + use crate::gui::ScreenMode; + use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + gui.diff_mode.set_focus(DiffModeFocus::SelectorA); + for modifiers in [KeyModifiers::SHIFT, KeyModifiers::NONE] { + gui.handle_key(KeyEvent::new(KeyCode::BackTab, modifiers)) + .unwrap(); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::DiffExploration); + gui.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::SelectorA); + } + gui.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT)) + .unwrap(); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::DiffExploration); + let stable_key = gui.current_diff_key(); + for (key, modifiers, expected) in [ + ('+', KeyModifiers::NONE, ScreenMode::Half), + ('+', KeyModifiers::SHIFT, ScreenMode::Full), + ('_', KeyModifiers::SHIFT, ScreenMode::Half), + ('_', KeyModifiers::NONE, ScreenMode::Normal), + ] { + gui.handle_key(KeyEvent::new(KeyCode::Char(key), modifiers)) + .unwrap(); + assert_eq!(gui.screen_mode, expected); + assert_eq!(gui.current_diff_key(), stable_key); + } + let config = Arc::get_mut(&mut gui.config).unwrap(); + config.user_config.keybinding.universal.toggle_panel_reverse = "".into(); + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .keybinding + .universal + .next_screen_mode = "".into(); + gui.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::Commits); + gui.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(gui.screen_mode, ScreenMode::Half); + gui.screen_mode = ScreenMode::Normal; + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .keybinding + .universal + .next_screen_mode = "+".into(); + gui.diff_mode.start_editing(DiffModeSelector::A); + gui.handle_key(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.screen_mode, ScreenMode::Normal); + gui.diff_mode.cancel_editing(); + gui.diff_mode.file_search_active = true; + gui.diff_mode.file_search_textarea = Some(tui_textarea::TextArea::default()); + gui.handle_key(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.screen_mode, ScreenMode::Normal); + gui.diff_mode.clear_list_search(); + gui.diff_mode.set_focus(DiffModeFocus::DiffExploration); + gui.diff_view.search_active = true; + gui.handle_key(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.screen_mode, ScreenMode::Normal); + gui.diff_view.search_active = false; + + let mouse = |gui: &mut Gui, kind, column, row| { + gui.handle_mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }) + }; + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .gui + .mouse_events = true; + for (width, height) in [(150, 40), (80, 40)] { + gui.layout.update_size(width, height); + gui.layout.side_panel_ratio = 0.4; + let layout = gui.compute_compare_layout(); + let (col, row) = if layout.portrait { + (5, layout.diff.y) + } else { + (layout.diff.x, 5) + }; + mouse(&mut gui, MouseEventKind::Down(MouseButton::Left), col, row); + assert!(gui.sidebar_resizing); + let (col, row) = if layout.portrait { (5, 25) } else { (100, 5) }; + mouse(&mut gui, MouseEventKind::Drag(MouseButton::Left), col, row); + let expected = if layout.portrait { + 25.0 / 39.0 + } else { + 100.0 / 150.0 + }; + assert!((gui.layout.side_panel_ratio - expected).abs() < 1e-8); + mouse(&mut gui, MouseEventKind::Up(MouseButton::Left), col, row); + assert!(!gui.sidebar_resizing); + // Grabbing the sidebar's bottom border must map to the same + // portrait split, rather than jumping by one row on mouse-down. + if layout.portrait { + let split = gui.compute_compare_layout().diff.y; + mouse( + &mut gui, + MouseEventKind::Down(MouseButton::Left), + 5, + split - 1, + ); + assert!(gui.sidebar_resizing); + assert_eq!(gui.compute_compare_layout().diff.y, split); + mouse( + &mut gui, + MouseEventKind::Up(MouseButton::Left), + 5, + split - 1, + ); + } + // Half/Full mode and disabled mouse input must not start resizing. + gui.screen_mode = ScreenMode::Half; + let half = gui.compute_compare_layout(); + let (col, row) = if half.portrait { + (5, half.diff.y) + } else { + (half.diff.x, 5) + }; + mouse(&mut gui, MouseEventKind::Down(MouseButton::Left), col, row); + assert!(!gui.sidebar_resizing); + gui.screen_mode = ScreenMode::Normal; + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .gui + .mouse_events = false; + let normal = gui.compute_compare_layout(); + let (col, row) = if normal.portrait { + (5, normal.diff.y) + } else { + (normal.diff.x, 5) + }; + mouse(&mut gui, MouseEventKind::Down(MouseButton::Left), col, row); + assert!(!gui.sidebar_resizing); + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .gui + .mouse_events = true; + let before = gui.layout.side_panel_ratio; + gui.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT)) + .unwrap(); + assert!((gui.layout.side_panel_ratio - (before - 0.05)).abs() < 1e-8); + gui.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::ALT)) + .unwrap(); + assert!((gui.layout.side_panel_ratio - before).abs() < 1e-8); + gui.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::ALT)) + .unwrap(); + assert_eq!( + gui.layout.side_panel_ratio, + if layout.portrait { 0.0 } else { 1.0 } + ); + gui.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::ALT)) + .unwrap(); + assert_eq!( + gui.layout.side_panel_ratio, + if layout.portrait { 1.0 } else { 0.0 } + ); + gui.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::ALT)) + .unwrap(); + assert_eq!( + gui.layout.side_panel_ratio, + gui.config.user_config.gui.side_panel_width + ); + } + + gui.diff_mode.ref_a = "missing".into(); + reload_diff_files(&mut gui).unwrap(); + assert!(gui.diff_mode.commits.is_empty()); + assert!(gui.diff_mode.tree_nodes.is_empty()); + assert!(gui.diff_mode.ahead_behind.is_none()); + } +} diff --git a/src/gui/controller/files.rs b/src/gui/controller/files.rs index 23755a0..e88ae89 100644 --- a/src/gui/controller/files.rs +++ b/src/gui/controller/files.rs @@ -19,24 +19,22 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) return super::commits::show_files_filtering_menu(gui); } - // Enter: toggle directory collapse in tree view, or focus diff for files - if key.code == KeyCode::Enter { - if gui.show_file_tree { - let selected = gui.context_mgr.selected_active(); - if let Some(node) = gui.file_tree_nodes.get(selected) { - if node.is_dir { - let path = node.path.clone(); - if gui.collapsed_dirs.contains(&path) { - gui.collapsed_dirs.remove(&path); - } else { - gui.collapsed_dirs.insert(path); - } - gui.update_file_tree_state(); - return Ok(()); + // Folding is explicit; Enter focuses file or combined directory diffs. + if gui.show_file_tree && super::tree::matches_key(key, &keybindings.universal.fold_directory) { + if let Some(node) = gui.file_tree_nodes.get(gui.context_mgr.selected_active()) { + if node.is_dir { + let path = node.path.clone(); + if !gui.collapsed_dirs.remove(&path) { + gui.collapsed_dirs.insert(path); } + gui.update_file_tree_state(); + gui.context_mgr.viewport_manually_scrolled = false; + gui.needs_diff_refresh = true; } } - // Focus the diff panel for the selected file + return Ok(()); + } + if key.code == KeyCode::Enter { if !gui.diff_view.is_empty() { gui.diff_focused = true; } diff --git a/src/gui/controller/mod.rs b/src/gui/controller/mod.rs index 54bd300..e70ce73 100644 --- a/src/gui/controller/mod.rs +++ b/src/gui/controller/mod.rs @@ -17,3 +17,5 @@ pub mod status; pub mod submodules; pub mod tags; pub mod worktrees; + +pub mod tree; diff --git a/src/gui/controller/tree.rs b/src/gui/controller/tree.rs new file mode 100644 index 0000000..adeed34 --- /dev/null +++ b/src/gui/controller/tree.rs @@ -0,0 +1,130 @@ +//! Shared tree shortcuts for sidebar and diff-focused navigation. +use crate::config::KeybindingConfig; +use crate::config::keybindings::parse_key; +use crate::model::file_tree::{self, NavigableTreeNode}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Outer Option means the key is claimed; inner Option means a destination exists. +/// Boundary keys are consumed too, so they cannot fall through to unrelated actions. +pub fn matches_key(mut key: KeyEvent, binding: &str) -> bool { + let Some(expected) = parse_key(binding) else { + return false; + }; + // Shifted punctuation may arrive with SHIFT on real terminals. + // Do not strip CTRL/ALT or SHIFT for letters / explicit modifier bindings. + if expected.modifiers.is_empty() + && matches!(expected.code, KeyCode::Char(c) if c.is_ascii_punctuation()) + { + key.modifiers.remove(KeyModifiers::SHIFT); + } + key.code == expected.code && key.modifiers == expected.modifiers +} + +pub fn destination( + key: KeyEvent, + kb: &KeybindingConfig, + nodes: &[T], + selected: usize, +) -> Option> { + let u = &kb.universal; + if matches_key(key, &u.tree_parent) { + Some(file_tree::find_parent_idx(nodes, selected)) + } else if matches_key(key, &u.tree_child) { + Some(file_tree::find_first_child_idx(nodes, selected)) + } else if matches_key(key, &u.tree_prev_sibling) { + Some(file_tree::find_prev_sibling_idx(nodes, selected)) + } else if matches_key(key, &u.tree_next_sibling) { + Some(file_tree::find_next_sibling_idx(nodes, selected)) + } else { + None + } +} + +pub fn command_section( + kb: &KeybindingConfig, + include_fold: bool, +) -> crate::gui::popup::CommandSection { + use crate::gui::popup::{CommandEntry, CommandSection}; + let u = &kb.universal; + let mut entries = Vec::new(); + if include_fold && !u.fold_directory.is_empty() { + entries.push(CommandEntry::keybinding( + u.fold_directory.clone(), + "Fold / unfold directory".into(), + )); + } + for (key, label) in [ + (&u.tree_parent, "Select parent"), + (&u.tree_child, "Select first visible child"), + (&u.tree_prev_sibling, "Select previous sibling"), + (&u.tree_next_sibling, "Select next sibling"), + ] { + if !key.is_empty() { + entries.push(CommandEntry::keybinding(key.clone(), label.into())); + } + } + CommandSection { + title: "File Tree".into(), + entries, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tree_help_reflects_custom_and_disabled_bindings() { + let mut kb = KeybindingConfig::default(); + kb.universal.fold_directory = "".into(); + kb.universal.tree_parent = "p".into(); + kb.universal.tree_child = "c".into(); + kb.universal.tree_prev_sibling.clear(); + kb.universal.tree_next_sibling = "n".into(); + for include_fold in [false, true] { + let section = command_section(&kb, include_fold); + assert_eq!(section.title, "File Tree"); + let keys: Vec<&str> = section.entries.iter().map(|e| e.key.as_str()).collect(); + assert_eq!( + keys, + if include_fold { + vec!["", "p", "c", "n"] + } else { + vec!["p", "c", "n"] + } + ); + } + kb.universal.fold_directory.clear(); + kb.universal.tree_parent.clear(); + kb.universal.tree_child.clear(); + kb.universal.tree_next_sibling.clear(); + assert!(command_section(&kb, true).entries.is_empty()); + } + + #[test] + fn tree_punctuation_matches_shift_but_rejects_extra_modifiers() { + assert!(matches_key( + KeyEvent::new(KeyCode::Char('>'), KeyModifiers::SHIFT), + ">" + )); + assert!(matches_key( + KeyEvent::new(KeyCode::Char('>'), KeyModifiers::NONE), + ">" + )); + assert!(!matches_key( + KeyEvent::new( + KeyCode::Char('>'), + KeyModifiers::SHIFT | KeyModifiers::CONTROL + ), + ">" + )); + assert!(!matches_key( + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::SHIFT), + "p" + )); + assert!(!matches_key( + KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE), + "" + )); + } +} diff --git a/src/gui/input.rs b/src/gui/input.rs index d8fc62b..ae8d5a9 100644 --- a/src/gui/input.rs +++ b/src/gui/input.rs @@ -20,6 +20,8 @@ //! sequence is dropped rather than replayed byte by byte. It is also lossless //! for real keys — anything that cannot appear inside the sequence being parsed //! is handed back as the keypress it is. +//! An Alt+underscore is ambiguous with an APC introducer, so its lookahead is +//! lossless: only a complete, validated Kitty reply is suppressed. use std::io; use std::sync::Arc; @@ -52,6 +54,9 @@ const SEQUENCE_DEADLINE: Duration = Duration::from_millis(400); /// Guard against a pathological run of parameter bytes. const MAX_SEQUENCE_BYTES: usize = 64; +/// Bound speculative Kitty reply buffering, including printable error text. +const MAX_KITTY_REPLY_BYTES: usize = 1024; + /// Most events handed to the UI in one batch. Keeps a paste storm or a very long /// auto-repeat burst from starving the renderer. const MAX_BATCH: usize = 512; @@ -170,28 +175,33 @@ fn next_events_interruptible( break event; } }; - let Event::Key(key) = first else { - return Ok(Some(vec![first])); - }; - if key.code != KeyCode::Esc || key.kind != KeyEventKind::Press { - return Ok(Some(vec![Event::Key(key)])); - } - resolve_escape(source).map(Some) + resolve_first_event(source, first).map(Some) } /// Blocking read of the next logical event(s). fn next_events(source: &mut impl EventSource) -> io::Result> { - // A bare `Esc` is the only event that can be the head of a sequence that - // crossterm failed to keep together, so everything else passes straight - // through. + // Both entry points share reassembly and lossless Kitty reply recognition. let first = loop { if let Some(event) = source.next(Duration::from_secs(3600))? { break event; } }; + resolve_first_event(source, first) +} + +fn resolve_first_event(source: &mut impl EventSource, first: Event) -> io::Result> { let Event::Key(key) = first else { return Ok(vec![first]); }; + // Crossterm parses an intact ESC _ (APC) as Alt+_, not as a control + // string. Validate the reply rather than swallowing genuine Alt shortcuts + // and whatever input follows them. + if key.kind == KeyEventKind::Press + && key.modifiers == KeyModifiers::ALT + && key.code == KeyCode::Char('_') + { + return resolve_kitty_reply(source, key); + } if key.code != KeyCode::Esc || key.kind != KeyEventKind::Press { return Ok(vec![Event::Key(key)]); } @@ -219,9 +229,14 @@ fn resolve_escape(source: &mut impl EventSource) -> io::Result> { match introducer { '[' => read_csi(source, deadline), 'O' => read_ss3(source, deadline), - // OSC/DCS/PM/APC carry a string payload that is never a shortcut. Read - // to its terminator and emit nothing. - ']' | 'P' | '^' | '_' => { + // A split APC and a genuine Alt+underscore use the same recognizer as + // the already-parsed Alt event. Do not infer a reply from '_' alone. + '_' => { + key.modifiers |= KeyModifiers::ALT; + resolve_kitty_reply(source, key) + } + // Existing OSC/DCS/PM reassembly remains unchanged. + ']' | 'P' | '^' => { consume_control_string(source, deadline)?; Ok(Vec::new()) } @@ -234,6 +249,94 @@ fn resolve_escape(source: &mut impl EventSource) -> io::Result> { } } +/// Keep all speculative events until a complete Kitty reply is confirmed. +/// No caller needs to track terminal query state or manage a replay queue. +fn resolve_kitty_reply( + source: &mut impl EventSource, + introducer: KeyEvent, +) -> io::Result> { + let mut events = vec![Event::Key(introducer)]; + let mut payload = String::new(); + let mut saw_esc = false; + let deadline = Instant::now() + SEQUENCE_DEADLINE; + loop { + let budget = if events.len() == 1 { + INTRODUCER_PROBE + } else { + CONTINUATION_WINDOW + }; + let Some(event) = + source.next(budget.min(deadline.saturating_duration_since(Instant::now())))? + else { + return Ok(events); + }; + events.push(event.clone()); + let Event::Key(key) = event else { + return Ok(events); + }; + if key.kind != KeyEventKind::Press { + return Ok(events); + } + if key.code == KeyCode::Char('\\') && (saw_esc || key.modifiers == KeyModifiers::ALT) { + return Ok(if is_kitty_reply(&payload) { + Vec::new() + } else { + events + }); + } + if saw_esc || key.modifiers != KeyModifiers::NONE { + return Ok(events); + } + if key.code == KeyCode::Esc { + saw_esc = true; + continue; + } + let KeyCode::Char(ch @ ' '..='~') = key.code else { + return Ok(events); + }; + payload.push(ch); + // Fail fast before waiting for a terminator on ordinary typed text. + let valid_prefix = match payload.len() { + 1 => payload == "G", + 2 => matches!(payload.as_str(), "Gi" | "GI"), + 3 => matches!(payload.as_str(), "Gi=" | "GI="), + _ => true, + }; + if !valid_prefix || payload.len() >= MAX_KITTY_REPLY_BYTES { + return Ok(events); + } + } +} + +/// Kitty acknowledges image identities with optional placement identities and +/// an ASCII status. Only this reply shape, not arbitrary APC text, is filtered. +fn is_kitty_reply(payload: &str) -> bool { + let Some((fields, status)) = payload.strip_prefix('G').and_then(|s| s.split_once(';')) else { + return false; + }; + if status.is_empty() || !status.bytes().all(|b| matches!(b, b' '..=b'~')) { + return false; + } + let mut has_image_id = false; + for field in fields.split(',') { + let Some((key, value)) = field.split_once('=') else { + return false; + }; + if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) { + return false; + } + let Ok(value) = value.parse::() else { + return false; + }; + match key { + "i" | "I" if value != 0 => has_image_id = true, + "p" => {} + _ => return false, + } + } + has_image_id +} + /// Read the body of a `CSI` sequence and turn it into the event it encodes. fn read_csi(source: &mut impl EventSource, deadline: Instant) -> io::Result> { let mut body = String::new(); @@ -312,7 +415,9 @@ fn consume_control_string(source: &mut impl EventSource, deadline: Instant) -> i }; match key.code { KeyCode::Esc => saw_esc = true, - KeyCode::Char('\\') if saw_esc => return Ok(()), + KeyCode::Char('\\') if saw_esc || key.modifiers == KeyModifiers::ALT => { + return Ok(()); + } KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => return Ok(()), _ => saw_esc = false, } @@ -606,4 +711,215 @@ mod tests { assert!(is_csi_final('u')); assert!(!is_csi_final('3')); } + + #[test] + fn kitty_reply_parsed_as_alt_introducer_is_not_a_shortcut() { + for interruptible in [false, true] { + let introducer = Event::Key(KeyEvent::new(KeyCode::Char('_'), KeyModifiers::ALT)); + let terminator = Event::Key(KeyEvent::new(KeyCode::Char('\\'), KeyModifiers::ALT)); + let mut items = vec![Some(introducer)]; + items.extend("Gi=31;OK".chars().map(ch)); + items.extend([Some(terminator), ch('q')]); + let mut source = Scripted::new(items); + let read = |source: &mut Scripted| { + if interruptible { + next_events_interruptible(source, &AtomicBool::new(false)).map(Option::unwrap) + } else { + next_events(source) + } + }; + assert!(read(&mut source).unwrap().is_empty()); + assert_eq!(read(&mut source).unwrap(), vec![key(KeyCode::Char('q'))]); + } + } + + #[test] + fn split_kitty_reply_is_not_a_shortcut() { + let mut items = vec![Some(key(KeyCode::Esc)), ch('_')]; + items.extend("Gi=31;OK".chars().map(ch)); + items.extend([Some(key(KeyCode::Esc)), ch('\\'), ch('q')]); + let mut source = Scripted::new(items); + assert!(next_events(&mut source).unwrap().is_empty()); + assert_eq!( + next_events(&mut source).unwrap(), + vec![key(KeyCode::Char('q'))] + ); + } + + #[test] + fn unrelated_alt_key_still_reaches_the_ui() { + let event = Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)); + assert_eq!( + next_events(&mut Scripted::new(vec![Some(event.clone())])).unwrap(), + vec![event] + ); + } + + fn read_scripted(source: &mut Scripted, interruptible: bool) -> Vec { + if interruptible { + next_events_interruptible(source, &AtomicBool::new(false)) + .unwrap() + .unwrap() + } else { + next_events(source).unwrap() + } + } + + #[test] + fn genuine_alt_control_introducers_and_following_input_are_preserved() { + for interruptible in [false, true] { + for c in [']', 'P', '^', '_'] { + let alt = Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::ALT)); + let next = key(KeyCode::Char('q')); + let mut source = Scripted::new(vec![Some(alt.clone()), Some(next.clone())]); + let mut received = read_scripted(&mut source, interruptible); + if !source.0.is_empty() { + received.extend(read_scripted(&mut source, interruptible)); + } + assert_eq!(received, vec![alt.clone(), next]); + + let mut source = Scripted::new(vec![Some(alt.clone()), None]); + assert_eq!(read_scripted(&mut source, interruptible), vec![alt]); + } + } + } + + #[test] + fn split_apc_introducer_preserves_genuine_alt_underscore() { + let mut source = Scripted::new(vec![Some(key(KeyCode::Esc)), ch('_'), ch('q')]); + assert_eq!( + next_events(&mut source).unwrap(), + vec![ + Event::Key(KeyEvent::new(KeyCode::Char('_'), KeyModifiers::ALT)), + key(KeyCode::Char('q')), + ] + ); + } + + #[test] + fn unconfirmed_kitty_reply_replays_every_event() { + let alt = Event::Key(KeyEvent::new(KeyCode::Char('_'), KeyModifiers::ALT)); + let terminator = Event::Key(KeyEvent::new(KeyCode::Char('\\'), KeyModifiers::ALT)); + for interruptible in [false, true] { + for payload in ["G", "Gi=", "Gi=31;OK", "Gi=x;OK", "Gi=31;"] { + let mut items = vec![Some(alt.clone())]; + items.extend(payload.chars().map(ch)); + if matches!(payload, "Gi=x;OK" | "Gi=31;") { + items.push(Some(terminator.clone())); + } else { + items.push(None); + } + let expected: Vec<_> = items.iter().flatten().cloned().collect(); + let mut source = Scripted::new(items); + let mut received = read_scripted(&mut source, interruptible); + while source.0.iter().any(Option::is_some) { + received.extend(read_scripted(&mut source, interruptible)); + } + assert_eq!(received, expected, "payload: {payload}"); + } + } + } + + #[test] + fn kitty_probe_preserves_non_payload_events() { + let alt = Event::Key(KeyEvent::new(KeyCode::Char('_'), KeyModifiers::ALT)); + for next in [ + Event::FocusGained, + Event::Resize(80, 24), + Event::Paste("paste".into()), + key(KeyCode::Enter), + ] { + let mut items = vec![Some(alt.clone())]; + items.extend("Gi=31;".chars().map(ch)); + items.push(Some(next)); + let expected: Vec<_> = items.iter().flatten().cloned().collect(); + assert_eq!(next_events(&mut Scripted::new(items)).unwrap(), expected); + } + } + #[test] + fn kitty_success_and_error_replies_accept_both_terminator_encodings() { + for interruptible in [false, true] { + for split_terminator in [false, true] { + for payload in [ + "Gi=31;OK", + "Gi=4294967295,p=2;ENOENT: image not found", + "GI=42,i=99;OK", + ] { + let mut items = vec![Some(Event::Key(KeyEvent::new( + KeyCode::Char('_'), + KeyModifiers::ALT, + )))]; + items.extend(payload.chars().map(ch)); + if split_terminator { + items.extend([Some(key(KeyCode::Esc)), ch('\\')]); + } else { + items.push(Some(Event::Key(KeyEvent::new( + KeyCode::Char('\\'), + KeyModifiers::ALT, + )))); + } + items.push(ch('q')); + let mut source = Scripted::new(items); + assert!(read_scripted(&mut source, interruptible).is_empty()); + assert_eq!( + read_scripted(&mut source, interruptible), + vec![key(KeyCode::Char('q'))] + ); + } + } + } + } + + #[test] + fn kitty_reply_validation_rejects_non_reply_metadata() { + for payload in [ + "G;OK", + "Gi=0;OK", + "Gi=4294967296;OK", + "Gi=-1;OK", + "Gi=1,p=x;OK", + "Gp=1;OK", + "Gi=1,a=t;OK", + "Gi=1;", + "Gi=1;OK\n", + ] { + assert!(!is_kitty_reply(payload), "{payload:?}"); + } + } + + #[test] + fn oversized_kitty_candidate_is_bounded_and_replayed() { + let mut items = vec![Some(Event::Key(KeyEvent::new( + KeyCode::Char('_'), + KeyModifiers::ALT, + )))]; + let payload = format!("Gi=31;{}", "x".repeat(MAX_KITTY_REPLY_BYTES)); + items.extend(payload.chars().map(ch)); + let expected: Vec<_> = items.iter().flatten().cloned().collect(); + let mut source = Scripted::new(items); + let mut received = next_events(&mut source).unwrap(); + assert_eq!(received.len(), MAX_KITTY_REPLY_BYTES + 1); + while !source.0.is_empty() { + received.extend(next_events(&mut source).unwrap()); + } + assert_eq!(received, expected); + } + + #[test] + fn alt_underscore_uses_only_the_short_initial_probe() { + struct TimedSource(Vec); + impl EventSource for TimedSource { + fn next(&mut self, timeout: Duration) -> io::Result> { + self.0.push(timeout); + Ok(None) + } + } + let alt = KeyEvent::new(KeyCode::Char('_'), KeyModifiers::ALT); + let mut source = TimedSource(Vec::new()); + assert_eq!( + resolve_first_event(&mut source, Event::Key(alt)).unwrap(), + vec![Event::Key(alt)] + ); + assert_eq!(source.0, vec![INTRODUCER_PROBE]); + } } diff --git a/src/gui/layout.rs b/src/gui/layout.rs index 67ecb10..7e17af0 100644 --- a/src/gui/layout.rs +++ b/src/gui/layout.rs @@ -47,7 +47,7 @@ const STATUS_PANEL_HEIGHT: u16 = 3; /// Portrait mode threshold: narrow terminal with enough vertical space. /// Available in both Normal and Half modes (Full mode stays full-screen). -fn should_use_portrait(width: u16, height: u16, screen_mode: ScreenMode) -> bool { +pub(crate) fn should_use_portrait(width: u16, height: u16, screen_mode: ScreenMode) -> bool { screen_mode != ScreenMode::Full && width <= 84 && height > 25 } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index f642801..b5d8061 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -292,7 +292,10 @@ struct DiffPreviewCache { } impl DiffPreviewCache { - fn insert(&mut self, key: String, view: DiffViewState) { + fn insert(&mut self, key: String, mut view: DiffViewState) { + for image in view.inline_images.values_mut() { + image.unload_for_cache(); + } self.remove(&key); let estimated_bytes = estimate_diff_view_bytes(&view); if estimated_bytes > MAX_CACHED_DIFF_BYTES { @@ -380,6 +383,17 @@ fn estimate_diff_view_bytes(view: &DiffViewState) -> usize { .saturating_add(view.new_content.len()) .saturating_add(line_bytes) .saturating_mul(2) + .saturating_add( + view.inline_images + .values() + .map(|image| image.estimated_bytes()) + .sum::(), + ) + .saturating_add( + view.image_preview + .as_ref() + .map_or(0, |p| p.estimated_bytes()), + ) } type BackgroundJob = Box; @@ -708,6 +722,8 @@ pub struct Gui { pub show_commit_file_tree: bool, /// Name of the branch/tag whose commits are being viewed in BranchCommits context. pub branch_commits_name: String, + /// Select HEAD once startup history arrives, unless the user navigates first. + pending_startup_head: bool, /// Name of the remote whose branches are being viewed in RemoteBranches context. pub remote_branches_name: String, /// Parent context to return to when pressing Esc from BranchCommits. @@ -816,7 +832,7 @@ fn synthesize_new_file_diff(filename: &str, content: &str) -> String { diff } -/// Placeholder so the pager shows "Binary file (not viewable)". +/// Synthetic binary diff so the pager renders its unavailable-preview placeholder. fn synthesize_binary_file_diff(filename: &str) -> String { format!( "diff --git a/{f} b/{f}\n\ @@ -866,6 +882,24 @@ fn parse_commit_file_diff_payload( current_path: &str, diff: &str, ) -> DiffPayload { + let paths = crate::git::diff::diff_paths_for_label(name); + let parent = format!("{hash}^1"); + if let Some(parsed) = image_diff_payload( + git, + name, + diff, + false, + crate::pager::image_preview::ImageSource::Revision { + revision: &parent, + path: paths[0], + }, + crate::pager::image_preview::ImageSource::Revision { + revision: hash, + path: current_path, + }, + ) { + return DiffPayload::Parsed(parsed); + } if is_rename_only_diff(diff) { if let Ok(content) = git.file_content_at_commit(hash, current_path) { if !content.is_empty() { @@ -882,6 +916,41 @@ fn parse_commit_file_diff_payload( DiffPayload::Parsed(DiffViewState::parse_diff_output(name, diff, 4, false)) } +/// Only single raster-file selections are previewed; multi-file buffers retain +/// their ordinary binary placeholders. Decoding validates the actual bytes. +fn image_diff_payload( + git: &GitCommands, + name: &str, + diff: &str, + exists: bool, + old: crate::pager::image_preview::ImageSource<'_>, + new: crate::pager::image_preview::ImageSource<'_>, +) -> Option { + let paths = crate::git::diff::diff_paths_for_label(name); + if !paths + .iter() + .any(|p| crate::pager::image_preview::is_image_path(p)) + { + return None; + } + let preview = crate::pager::image_preview::load(git, old, new)?; + // A rename-only image has no binary marker, but must still be a nonempty + // diff view without text hunks (image content cannot be staged by hunk). + let placeholder = synthesize_binary_file_diff(name); + let mut parsed = DiffViewState::parse_diff_output( + name, + if diff.is_empty() || is_rename_only_diff(diff) { + &placeholder + } else { + diff + }, + 4, + exists, + ); + parsed.image_preview = Some(preview); + Some(parsed) +} + impl Gui { fn show_error(&mut self, title: &str, err: anyhow::Error) { self.popup = PopupState::Message { @@ -891,7 +960,12 @@ impl Gui { }; } - pub fn new(config: AppConfig, git: GitCommands, filter_path: Option) -> Result { + pub fn new( + config: AppConfig, + git: GitCommands, + filter_path: Option, + start_in_commits: bool, + ) -> Result { let (diff_tx, diff_rx) = mpsc::channel(); let (diff_scheduler_tx, diff_scheduler_rx) = mpsc::channel(); let (diff_prefetch_tx, diff_prefetch_rx) = mpsc::channel(); @@ -983,8 +1057,9 @@ impl Gui { .unwrap_or(0); let mut context_mgr = ContextManager::new(); - if startup_path_filter.is_some() { + if startup_path_filter.is_some() || start_in_commits { context_mgr.set_active(ContextId::Commits); + context_mgr.set_selection_for(ContextId::Commits, 0); } Ok(Self { @@ -1070,6 +1145,7 @@ impl Gui { commit_files_collapsed_dirs: HashSet::new(), show_commit_file_tree: show_file_tree, branch_commits_name: String::new(), + pending_startup_head: start_in_commits, remote_branches_name: String::new(), sub_commits_parent_context: context::ContextId::Branches, commit_files_parent_context: None, @@ -1117,6 +1193,9 @@ impl Gui { let mut keyboard_enhanced = keyboard_enhanced; let result = self.main_loop(&mut terminal, &input, &mut keyboard_enhanced); + self.diff_preview_cache.clear(); + self.diff_view.reset_keep_prefs(); + self.displayed_diff_key.clear(); restore_terminal(&mut terminal, keyboard_enhanced)?; result } @@ -1154,6 +1233,13 @@ impl Gui { // event reader is process-wide and would steal hx/nvim's keystrokes. input.pause(); input.drain(); + // Dropping the cached protocols queues scoped deletes and ensures they + // are freshly transmitted after returning from another alternate screen. + self.diff_preview_cache.clear(); + self.diff_view.reset_keep_prefs(); + self.displayed_diff_key.clear(); + self.needs_diff_refresh = true; + crate::pager::image_preview::flush_cleanup(terminal.backend_mut()); restore_terminal(terminal, *keyboard_enhanced)?; // LeaveAlternateScreen restores the previous buffer; wipe the primary @@ -1219,6 +1305,7 @@ impl Gui { // Drain any model parts that have arrived from the background load. if let Some(rx) = &self.initial_load_rx { let mut got_files = false; + let mut got_commits = false; let mut got_rebase_in_progress = false; let received_before = self.initial_load_received; while let Ok(part) = rx.try_recv() { @@ -1230,6 +1317,7 @@ impl Gui { } ModelPart::Branches(v) => model.branches = v, ModelPart::Commits(v) => { + got_commits = true; // Stream already applies the active filter when one is set // (`load_model_streaming(commit_filter)`), so always take it. self.commit_history_complete = v.len() < DEFAULT_COMMIT_LIMIT; @@ -1270,6 +1358,11 @@ impl Gui { } self.initial_load_received += 1; } + if got_commits { + if let Err(err) = self.select_startup_head() { + self.show_error("Could not select HEAD", err); + } + } // Enter the InProgress rebase view as soon as we know a rebase // is on disk — don't wait for a future `refresh()` tick (focus // event / auto-refresh interval), which is what made the view @@ -1354,6 +1447,18 @@ impl Gui { // Render let theme = self.active_theme(); + crate::pager::image_preview::flush_cleanup(terminal.backend_mut()); + self.diff_view.image_preview_hidden = self.popup != PopupState::None + || (self.show_command_log + && self + .diff_view + .image_preview + .as_ref() + .is_some_and(|p| !p.uses_placeholders())) + || self.ai_commit_generation_active() + || self.remote_op_label.is_some() + || self.rebase_mode.active + || self.patch_building.active; terminal.draw(|frame| { if self.rebase_mode.active { presentation::rebase_mode::render(frame, &mut self.rebase_mode, &theme); @@ -1409,6 +1514,10 @@ impl Gui { &theme, self.diff_loading, diff_loading_show, + &mut self.commit_list_cache, + self.layout.side_panel_ratio, + self.screen_mode, + &self.config.user_config.keybinding, ); // Render popup overlay on top of diff mode (for ? help, errors, etc.) if self.popup != PopupState::None { @@ -1756,28 +1865,7 @@ impl Gui { fn current_diff_key(&self) -> String { if self.diff_mode.active { - let item_key = if self.diff_mode.show_tree { - self.diff_mode - .tree_nodes - .get(self.diff_mode.diff_files_selected) - .map(|node| { - node.file_index - .and_then(|index| self.diff_mode.diff_files.get(index)) - .map(|file| format!("file:{}", file.name)) - .unwrap_or_else(|| format!("dir:{}", node.path)) - }) - .unwrap_or_else(|| "none".to_string()) - } else { - self.diff_mode - .diff_files - .get(self.diff_mode.diff_files_selected) - .map(|file| format!("file:{}", file.name)) - .unwrap_or_else(|| "none".to_string()) - }; - return format!( - "DiffMode:{}..{}:{}", - self.diff_mode.ref_a, self.diff_mode.ref_b, item_key - ); + return self.diff_mode.diff_key(); } let active = self.context_mgr.active(); @@ -2951,6 +3039,7 @@ impl Gui { let has_staged = file.has_staged_changes; let has_unstaged = file.has_unstaged_changes; let tracked = file.tracked; + let conflicted = file.has_merge_conflicts; drop(model); let git = Arc::clone(&self.git); @@ -2959,6 +3048,39 @@ impl Gui { self.diff_loading_since = Some(Instant::now()); self.queue_diff_job(generation, diff_key, move || { let path_refs: Vec<&str> = diff_paths.iter().map(String::as_str).collect(); + use crate::pager::image_preview::ImageSource; + let old_path = path_refs[0]; + let old = if !tracked { + ImageSource::Missing + } else { + ImageSource::Revision { + revision: if has_staged { "HEAD" } else { "" }, + path: old_path, + } + }; + let new = if has_staged && !has_unstaged { + ImageSource::Revision { + revision: "", + path: ¤t_path, + } + } else { + ImageSource::Worktree(¤t_path) + }; + if let Some(parsed) = (!conflicted) + .then(|| { + image_diff_payload( + &git, + &name, + "", + git.repo_path().join(¤t_path).exists(), + old, + new, + ) + }) + .flatten() + { + return DiffPayload::Parsed(parsed); + } // Single HEAD buffer with coherent line numbers; // hunks are dimmed/tinted staged vs unstaged via // overlap with the unstaged diff. Falls back to the @@ -3069,6 +3191,13 @@ impl Gui { .filter(|f| !f.tracked) .map(|f| f.current_path().to_string()) .collect(); + let excluded: std::collections::HashSet = node + .child_file_indices + .iter() + .filter_map(|&i| model.files.get(i)) + .filter(|f| f.has_merge_conflicts) + .flat_map(|f| f.diff_paths().into_iter().map(str::to_string)) + .collect(); let pathspec = pathspec_for_tree_path(&node.path); let dir_name = node.name.clone(); drop(model); @@ -3137,6 +3266,14 @@ impl Gui { if parsed.hunk_staged.len() != parsed.hunk_starts.len() { parsed.hunk_staged = vec![false; parsed.hunk_starts.len()]; } + crate::pager::image_preview::attach_inline_image_previews( + &mut parsed, + git.repo_path(), + &combined_diff, + "HEAD", + None, + &excluded, + ); if parsed.lines.is_empty() { DiffPayload::Empty } else { @@ -3293,12 +3430,22 @@ impl Gui { if combined_diff.is_empty() { DiffPayload::Empty } else { - DiffPayload::Parsed(DiffViewState::parse_diff_output( + let mut parsed = DiffViewState::parse_diff_output( &dir_name, &combined_diff, 4, true, - )) + ); + let parent = format!("{hash}^1"); + crate::pager::image_preview::attach_inline_image_previews( + &mut parsed, + git.repo_path(), + &combined_diff, + &parent, + Some(&hash), + &HashSet::new(), + ); + DiffPayload::Parsed(parsed) } }); } else { @@ -3334,7 +3481,66 @@ impl Gui { Ok(false) } + /// Split-resize shortcuts shared by the main UI and compare mode. + fn try_handle_panel_resize_key(&mut self, key: KeyEvent) -> bool { + let keybindings = &self.config.user_config.keybinding; + // Side-panel resize: orientation-aware. + // Portrait (vertical stack): side on top, diff on bottom. + // Alt+h/l → shrink/expand by step + // Alt+k → diff pane full (ratio 0.0), Alt+j → side pane full (ratio 1.0) + // Landscape (horizontal split): side on left, diff on right. + // Alt+h/l → shrink/expand by step, Alt+k → side full, Alt+j → main full + let portrait = + layout::should_use_portrait(self.layout.width, self.layout.height, self.screen_mode); + let shrink_key = matches_key(key, &keybindings.universal.shrink_side_panel); + let expand_key = matches_key(key, &keybindings.universal.expand_side_panel); + if shrink_key || expand_key { + const STEP: f64 = 0.05; + let delta = if shrink_key { -STEP } else { STEP }; + self.layout.side_panel_ratio = (self.layout.side_panel_ratio + delta).clamp(0.0, 1.0); + return true; + } + if matches_key(key, &keybindings.universal.side_panel_full) { + // Alt+k: diff full in portrait, side full in landscape + self.layout.side_panel_ratio = if portrait { 0.0 } else { 1.0 }; + return true; + } + if matches_key(key, &keybindings.universal.main_panel_full) { + // Alt+j: side full in portrait, main full in landscape + self.layout.side_panel_ratio = if portrait { 1.0 } else { 0.0 }; + return true; + } + if matches_key(key, &keybindings.universal.reset_side_panel) { + self.layout.side_panel_ratio = self.config.user_config.gui.side_panel_width; + return true; + } + + false + } + + /// Compare layout shortcuts run only after its text-input handlers. + fn try_handle_layout_key(&mut self, mut key: KeyEvent) -> bool { + if self.try_handle_panel_resize_key(key) { + return true; + } + // Terminals may report shifted punctuation with or without SHIFT. + if matches!(key.code, KeyCode::Char('+') | KeyCode::Char('_')) { + key.modifiers.remove(KeyModifiers::SHIFT); + } + let keybindings = &self.config.user_config.keybinding; + if matches_key(key, &keybindings.universal.next_screen_mode) { + self.next_screen_mode(); + return true; + } + if matches_key(key, &keybindings.universal.prev_screen_mode) { + self.prev_screen_mode(); + return true; + } + false + } + fn handle_key(&mut self, key: KeyEvent) -> Result<()> { + self.pending_startup_head = false; if self.handle_ai_commit_cancel_key(key) { return Ok(()); } @@ -3370,39 +3576,10 @@ impl Gui { return controller::diff_mode::handle_key(self, key); } - let keybindings = &self.config.user_config.keybinding; - - // Side-panel resize: orientation-aware. - // Portrait (vertical stack): side on top, diff on bottom. - // Alt+h/l → shrink/expand by step - // Alt+k → diff pane full (ratio 0.0), Alt+j → side pane full (ratio 1.0) - // Landscape (horizontal split): side on left, diff on right. - // Alt+h/l → shrink/expand by step, Alt+k → side full, Alt+j → main full - let portrait = self.screen_mode != ScreenMode::Full - && self.layout.width <= 84 - && self.layout.height > 25; - let shrink_key = matches_key(key, &keybindings.universal.shrink_side_panel); - let expand_key = matches_key(key, &keybindings.universal.expand_side_panel); - if shrink_key || expand_key { - const STEP: f64 = 0.05; - let delta = if shrink_key { -STEP } else { STEP }; - self.layout.side_panel_ratio = (self.layout.side_panel_ratio + delta).clamp(0.0, 1.0); - return Ok(()); - } - if matches_key(key, &keybindings.universal.side_panel_full) { - // Alt+k: diff full in portrait, side full in landscape - self.layout.side_panel_ratio = if portrait { 0.0 } else { 1.0 }; - return Ok(()); - } - if matches_key(key, &keybindings.universal.main_panel_full) { - // Alt+j: side full in portrait, main full in landscape - self.layout.side_panel_ratio = if portrait { 1.0 } else { 0.0 }; - return Ok(()); - } - if matches_key(key, &keybindings.universal.reset_side_panel) { - self.layout.side_panel_ratio = self.config.user_config.gui.side_panel_width; + if self.try_handle_panel_resize_key(key) { return Ok(()); } + let keybindings = &self.config.user_config.keybinding; if matches_key(key, &keybindings.universal.toggle_diff_view_layout) { self.diff_view.toggle_view_layout(); @@ -3429,6 +3606,11 @@ impl Gui { return self.handle_diff_focused_key(key); } + // Toggle between working-tree changes and the actual checked-out HEAD. + if matches_key(key, &keybindings.universal.toggle_working_tree_and_head) { + return self.toggle_working_tree_and_head(); + } + // Global keybindings if matches_key(key, &keybindings.universal.quit) || matches_key(key, &keybindings.universal.quit_alt1) @@ -3492,6 +3674,11 @@ impl Gui { return Ok(()); } + if self.try_handle_tree_navigation(key) { + return Ok(()); + } + let keybindings = &self.config.user_config.keybinding; + // Arrow keys / h/l to switch windows if matches_key(key, &keybindings.universal.prev_block) || matches_key(key, &keybindings.universal.prev_block_alt) @@ -3705,10 +3892,10 @@ impl Gui { return Ok(()); } - // `.` toggles the commit-details box when in any commit-related + // `'` toggles the commit-details box when in any commit-related // context. Kept outside per-context controllers so the binding is // consistent across Commits / BranchCommits / Reflog / CommitFiles. - if key.code == KeyCode::Char('.') && self.context_has_commit_details() { + if key.code == KeyCode::Char('\'') && self.context_has_commit_details() { self.show_commit_details = !self.show_commit_details; self.persist_commit_details_visibility(); return Ok(()); @@ -3723,6 +3910,41 @@ impl Gui { Ok(()) } + fn active_file_tree(&self) -> bool { + match self.context_mgr.active() { + ContextId::Files => self.show_file_tree, + ContextId::CommitFiles | ContextId::BranchCommitFiles | ContextId::StashFiles => { + self.show_commit_file_tree + } + _ => false, + } + } + + fn try_handle_tree_navigation(&mut self, key: KeyEvent) -> bool { + let active = self.context_mgr.active(); + let selected = self.context_mgr.selected_active(); + let kb = &self.config.user_config.keybinding; + let destination = match active { + ContextId::Files if self.show_file_tree => { + controller::tree::destination(key, kb, &self.file_tree_nodes, selected) + } + ContextId::CommitFiles | ContextId::BranchCommitFiles | ContextId::StashFiles + if self.show_commit_file_tree => + { + controller::tree::destination(key, kb, &self.commit_file_tree_nodes, selected) + } + _ => None, + }; + if let Some(destination) = destination { + if let Some(idx) = destination { + self.context_mgr.set_selection(idx); + self.needs_diff_refresh = true; + } + return true; + } + false + } + fn handle_context_key(&mut self, key: KeyEvent) -> Result<()> { let keybindings = self.config.user_config.keybinding.clone(); let active = self.context_mgr.active(); @@ -3827,7 +4049,7 @@ impl Gui { .map(|(line_idx, _, panel)| (line_idx, panel)) .unwrap_or_else(|| { ( - self.diff_view.scroll_offset + (top_row - pl.inner_y) as usize, + self.diff_view.fallback_line_idx_for_row(top_row, &pl), sel_ref.panel, ) }) @@ -3949,6 +4171,16 @@ impl Gui { return Ok(()); } + // Respect the configured binding in the diff pane too. + if matches_key(key, &keybindings.universal.toggle_working_tree_and_head) { + return self.toggle_working_tree_and_head(); + } + + if self.try_handle_tree_navigation(key) { + return Ok(()); + } + let keybindings = &self.config.user_config.keybinding; + // Toggle command log (;) if key.code == KeyCode::Char(';') { self.show_command_log = !self.show_command_log; @@ -5538,7 +5770,11 @@ impl Gui { for commit in model.commits.iter().skip(1) { items.push(ListPickerItem { value: commit.hash.clone(), - label: format!("{} {}", commit.short_hash(), commit.name), + label: format!( + "{} {}", + commit.short_hash(), + presentation::text::plain_text(&commit.name) + ), category: "Commits".to_string(), description: None, }); @@ -5611,6 +5847,10 @@ impl Gui { kb.universal.create_patch_options_menu.clone(), "Patch options".into(), ), + CommandEntry::keybinding( + kb.universal.toggle_working_tree_and_head.clone(), + "Toggle files / commit HEAD".into(), + ), CommandEntry::keybinding("{/}".into(), "Previous/next hunk".into()), CommandEntry::keybinding(";".into(), "Toggle command log".into()), CommandEntry::keybinding("W".into(), "Compare / Diff mode".into()), @@ -5631,7 +5871,10 @@ impl Gui { ContextId::Files => CommandSection { title: "Files".into(), entries: vec![ - CommandEntry::keybinding("".into(), "Toggle dir / Focus diff".into()), + CommandEntry::keybinding( + "".into(), + "Focus file / directory diff".into(), + ), CommandEntry::keybinding("".into(), "Stage / Unstage".into()), CommandEntry::keybinding( kb.universal.toggle_diff_view_layout.clone(), @@ -5752,7 +5995,7 @@ impl Gui { kb.universal.toggle_diff_view_layout.clone(), "Toggle unified / side-by-side view".into(), ), - CommandEntry::keybinding(".".into(), "Toggle commit details panel".into()), + CommandEntry::keybinding("'".into(), "Toggle commit details panel".into()), ]; // Grep only applies to the files list, not the commits list. if active == ContextId::BranchCommitFiles { @@ -5836,7 +6079,7 @@ impl Gui { kb.commits.open_log_menu.clone(), "Filter commits".into(), ), - CommandEntry::keybinding(".".into(), "Toggle commit details panel".into()), + CommandEntry::keybinding("'".into(), "Toggle commit details panel".into()), ]; if !self.cherry_pick_clipboard.is_empty() { entries.insert( @@ -5847,6 +6090,15 @@ impl Gui { ), ); } + if self.commit_path_filter.is_some() + || !self.commit_author_filter.is_empty() + || !self.commit_branch_filter.is_empty() + { + entries.insert( + 0, + CommandEntry::keybinding("".into(), "Reset commit filter".into()), + ); + } CommandSection { title: "Commits".into(), entries, @@ -5855,7 +6107,10 @@ impl Gui { ContextId::CommitFiles => CommandSection { title: "Commit Files".into(), entries: vec![ - CommandEntry::keybinding("".into(), "Toggle dir / Focus diff".into()), + CommandEntry::keybinding( + "".into(), + "Focus file / directory diff".into(), + ), CommandEntry::keybinding("".into(), "Back to commits".into()), CommandEntry::keybinding("".into(), "Grep diff contents".into()), CommandEntry::keybinding(kb.universal.edit.clone(), "Edit file".into()), @@ -5869,7 +6124,7 @@ impl Gui { "Toggle tree view".into(), ), CommandEntry::keybinding("y".into(), "Copy to clipboard menu".into()), - CommandEntry::keybinding(".".into(), "Toggle commit details panel".into()), + CommandEntry::keybinding("'".into(), "Toggle commit details panel".into()), ], }, ContextId::Reflog => CommandSection { @@ -5893,7 +6148,7 @@ impl Gui { "Copy (cherry-pick)".into(), ), CommandEntry::keybinding("y".into(), "Copy to clipboard menu".into()), - CommandEntry::keybinding(".".into(), "Toggle commit details panel".into()), + CommandEntry::keybinding("'".into(), "Toggle commit details panel".into()), ], }, ContextId::Stash => CommandSection { @@ -5913,7 +6168,10 @@ impl Gui { ContextId::StashFiles => CommandSection { title: "Stash Files".into(), entries: vec![ - CommandEntry::keybinding("".into(), "Toggle dir / Focus diff".into()), + CommandEntry::keybinding( + "".into(), + "Focus file / directory diff".into(), + ), CommandEntry::keybinding("".into(), "Back to stash".into()), CommandEntry::keybinding("".into(), "Grep diff contents".into()), CommandEntry::keybinding( @@ -5981,7 +6239,10 @@ impl Gui { }, }; - let sections = vec![context_section, universal]; + let mut sections = vec![context_section, universal]; + if self.active_file_tree() { + sections.insert(1, controller::tree::command_section(kb, true)); + } self.popup = PopupState::CommandPalette { sections, @@ -6019,6 +6280,15 @@ impl Gui { .clone(), "Toggle unified / side-by-side view".into(), ), + CommandEntry::keybinding( + self.config + .user_config + .keybinding + .universal + .toggle_working_tree_and_head + .clone(), + "Toggle files / commit HEAD".into(), + ), CommandEntry::keybinding("z".into(), "Toggle line wrap".into()), CommandEntry::keybinding("g/G".into(), "Go to top / bottom".into()), CommandEntry::keybinding("PgUp/PgDn".into(), "Page up / down".into()), @@ -6072,8 +6342,15 @@ impl Gui { entries, }; + let mut sections = vec![diff_section]; + if self.active_file_tree() { + sections.push(controller::tree::command_section( + &self.config.user_config.keybinding, + false, + )); + } self.popup = PopupState::CommandPalette { - sections: vec![diff_section], + sections, selected: 0, search_textarea: popup::make_command_palette_search_textarea(), scroll_offset: 0, @@ -6692,6 +6969,7 @@ impl Gui { } } else if self.popup == PopupState::None && self.screen_mode == ScreenMode::Normal + && (!self.diff_mode.active || self.diff_mode.editing.is_none()) && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && self.sidebar_divider_hit(mouse.column, mouse.row) { @@ -7034,6 +7312,13 @@ impl Gui { self.diff_focused = true; return; } + // Clicks on the pinned sticky file header don't start a + // text selection — there is no content to select there. + if self.diff_view.is_sticky_row(mouse.row, &pl) { + self.diff_view.selection = None; + self.diff_focused = true; + return; + } if let Some(panel) = pl.panel_at_x(mouse.column) { self.diff_view.selection = Some(TextSelection { panel, @@ -7167,7 +7452,7 @@ impl Gui { fn handle_diff_mode_mouse(&mut self, mouse: MouseEvent) { use self::modes::diff_mode::{DiffModeFocus, DiffModeSelector}; use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; - use ratatui::layout::{Constraint, Direction, Layout, Rect}; + use ratatui::layout::Rect; // Help popup intercepts mouse scroll if let PopupState::CommandPalette { @@ -7226,30 +7511,14 @@ impl Gui { let area = Rect::new(0, 0, self.layout.width, self.layout.height); - // Replicate the diff mode layout to determine regions - let outer = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(1)]) - .split(area); - - let content = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(33), Constraint::Percentage(67)]) - .split(outer[0]); - - let sidebar = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Min(1), - ]) - .split(content[0]); + let compare_layout = self.compute_compare_layout(); + let sidebar = compare_layout.sidebar; let selector_a_rect = sidebar[0]; let selector_b_rect = sidebar[1]; let files_rect = sidebar[2]; - let diff_rect = content[1]; + let commits_rect = sidebar[3]; + let diff_rect = compare_layout.diff; let col = mouse.column; let row = mouse.row; @@ -7286,9 +7555,9 @@ impl Gui { self.diff_mode.confirm_selection(); if self.diff_mode.has_both_refs() { let _ = crate::gui::controller::diff_mode::reload_diff_files(self); - self.diff_mode.focus = DiffModeFocus::CommitFiles; + self.diff_mode.set_focus(DiffModeFocus::CommitFiles); } else if self.diff_mode.ref_a.is_empty() { - self.diff_mode.focus = DiffModeFocus::SelectorA; + self.diff_mode.set_focus(DiffModeFocus::SelectorA); self.diff_mode.start_editing(DiffModeSelector::A); let model = self.model.lock().unwrap(); self.diff_mode.search_refs( @@ -7299,7 +7568,7 @@ impl Gui { &model.head_branch_name, ); } else { - self.diff_mode.focus = DiffModeFocus::SelectorB; + self.diff_mode.set_focus(DiffModeFocus::SelectorB); self.diff_mode.start_editing(DiffModeSelector::B); let model = self.model.lock().unwrap(); self.diff_mode.search_refs( @@ -7342,7 +7611,12 @@ impl Gui { if rect_contains(diff_rect, col, row) && !self.diff_view.is_empty() { let pl = DiffPanelLayout::compute(diff_rect, &self.diff_view); if self.try_handle_revert_block_click(diff_rect, pl, col, row) { - self.diff_mode.focus = DiffModeFocus::DiffExploration; + self.diff_mode.set_focus(DiffModeFocus::DiffExploration); + return; + } + if self.diff_view.is_sticky_row(row, &pl) { + self.diff_view.selection = None; + self.diff_mode.set_focus(DiffModeFocus::DiffExploration); return; } if let Some(panel) = pl.panel_at_x(col) { @@ -7361,13 +7635,13 @@ impl Gui { } else { self.diff_view.selection = None; } - self.diff_mode.focus = DiffModeFocus::DiffExploration; + self.diff_mode.set_focus(DiffModeFocus::DiffExploration); } else { self.diff_view.selection = None; // Click on panels to switch focus if rect_contains(selector_a_rect, col, row) { - self.diff_mode.focus = DiffModeFocus::SelectorA; + self.diff_mode.set_focus(DiffModeFocus::SelectorA); // Start editing on click self.diff_mode.start_editing(DiffModeSelector::A); let model = self.model.lock().unwrap(); @@ -7379,7 +7653,7 @@ impl Gui { &model.head_branch_name, ); } else if rect_contains(selector_b_rect, col, row) { - self.diff_mode.focus = DiffModeFocus::SelectorB; + self.diff_mode.set_focus(DiffModeFocus::SelectorB); // Start editing on click self.diff_mode.start_editing(DiffModeSelector::B); let model = self.model.lock().unwrap(); @@ -7391,7 +7665,8 @@ impl Gui { &model.head_branch_name, ); } else if rect_contains(files_rect, col, row) { - self.diff_mode.focus = DiffModeFocus::CommitFiles; + self.diff_mode.set_focus(DiffModeFocus::CommitFiles); + self.needs_diff_refresh = true; // Click to select a file — use stored scroll offset let inner_y = row.saturating_sub(files_rect.y + 1); let len = self.diff_mode.visible_files_len(); @@ -7401,8 +7676,20 @@ impl Gui { self.diff_mode.viewport_manually_scrolled = false; self.needs_diff_refresh = true; } + } else if rect_contains(commits_rect, col, row) { + self.diff_mode.set_focus(DiffModeFocus::Commits); + let inner_y = row.saturating_sub(commits_rect.y + 1); + let clicked_idx = self.diff_mode.commits_scroll + inner_y as usize; + if row > commits_rect.y + && row < commits_rect.bottom().saturating_sub(1) + && clicked_idx < self.diff_mode.commits.len() + { + self.diff_mode.commits_selected = clicked_idx; + self.diff_mode.commits_viewport_manually_scrolled = false; + } + self.needs_diff_refresh = true; } else if rect_contains(diff_rect, col, row) { - self.diff_mode.focus = DiffModeFocus::DiffExploration; + self.diff_mode.set_focus(DiffModeFocus::DiffExploration); } } } @@ -7448,6 +7735,14 @@ impl Gui { visible_height, ); self.diff_mode.viewport_manually_scrolled = true; + } else if rect_contains(commits_rect, col, row) { + scroll::scroll_viewport( + &mut self.diff_mode.commits_scroll, + -3, + self.diff_mode.commits.len(), + commits_rect.height.saturating_sub(2) as usize, + ); + self.diff_mode.commits_viewport_manually_scrolled = true; } } MouseEventKind::ScrollDown => { @@ -7469,6 +7764,26 @@ impl Gui { visible_height, ); self.diff_mode.viewport_manually_scrolled = true; + } else if rect_contains(commits_rect, col, row) { + let visible_height = commits_rect.height.saturating_sub(2) as usize; + if self.diff_mode.commits_scroll + visible_height + 3 + >= self.diff_mode.commits.len() + { + if let Err(error) = controller::diff_mode::load_more_commits(self) { + self.popup = PopupState::Message { + title: "Compare commits".into(), + message: error.to_string(), + kind: popup::MessageKind::Error, + }; + } + } + scroll::scroll_viewport( + &mut self.diff_mode.commits_scroll, + 3, + self.diff_mode.commits.len(), + visible_height, + ); + self.diff_mode.commits_viewport_manually_scrolled = true; } } MouseEventKind::ScrollLeft => { @@ -7621,6 +7936,15 @@ impl Gui { ) } + fn compute_compare_layout(&self) -> modes::diff_mode::CompareLayout { + modes::diff_mode::CompareLayout::new( + ratatui::layout::Rect::new(0, 0, self.layout.width, self.layout.height), + self.layout.side_panel_ratio, + self.screen_mode, + &self.diff_mode, + ) + } + /// Content area above the status bar (side + main live here). fn content_area_rect(&self) -> ratatui::layout::Rect { ratatui::layout::Rect::new( @@ -7643,6 +7967,9 @@ impl Gui { return false; } + if self.diff_mode.active { + return self.compute_compare_layout().divider_hit(content, col, row); + } let fl = self.compute_current_frame_layout(); if fl.portrait { @@ -7692,6 +8019,9 @@ impl Gui { /// Main/diff top border: 0 (row is already the split). Expanded panel bottom: /// 1 + trailing collapsed panels so both grabs drive the same ratio. fn portrait_sidebar_resize_offset(&self, row: u16) -> u16 { + if self.diff_mode.active { + return self.compute_compare_layout().resize_row_offset(row); + } let fl = self.compute_current_frame_layout(); if !fl.portrait { return 0; @@ -7717,8 +8047,12 @@ impl Gui { if content.width == 0 || content.height == 0 { return; } - let fl = self.compute_current_frame_layout(); - let ratio = if fl.portrait { + let portrait = if self.diff_mode.active { + self.compute_compare_layout().portrait + } else { + self.compute_current_frame_layout().portrait + }; + let ratio = if portrait { let side_end = row .saturating_sub(content.y) .saturating_add(self.sidebar_resize_row_offset) @@ -7732,7 +8066,7 @@ impl Gui { } /// True when the active context is one where commit-details makes sense - /// (drives both the `.` toggle and layout-time `show_details`). + /// (drives both the `'` toggle and layout-time `show_details`). fn context_has_commit_details(&self) -> bool { matches!( self.context_mgr.active(), @@ -8491,6 +8825,62 @@ impl Gui { } } + fn select_startup_head(&mut self) -> Result<()> { + if std::mem::take(&mut self.pending_startup_head) + && self.context_mgr.active() == ContextId::Commits + { + self.focus_head_commit()?; + } + Ok(()) + } + + fn focus_head_commit(&mut self) -> Result<()> { + // HEAD may not be the first all-ref commit, and may be absent from a + // filtered or paginated list. Never change filters or inject a commit + // that doesn't match them: reuse the separate revision-history view. + let hash = self.git.head_hash().unwrap_or_default(); + let selected = self + .model + .lock() + .unwrap() + .commits + .iter() + .position(|commit| commit.hash == hash); + let (context, index) = if let Some(index) = selected { + (ContextId::Commits, index) + } else if hash.is_empty() { + // Unborn repository: an empty commit panel is still a valid target. + (ContextId::Commits, 0) + } else { + let commits = self.git.load_commits_for_branch(&hash, 300)?; + self.model.lock().unwrap().set_sub_commits(commits); + self.branch_commits_name = "HEAD".to_string(); + self.sub_commits_parent_context = ContextId::Commits; + (ContextId::BranchCommits, 0) + }; + self.context_mgr.set_active(context); + self.context_mgr.set_selection(index); + self.context_mgr.set_scroll_offset(context, index); + self.needs_diff_refresh = true; + Ok(()) + } + + pub fn toggle_working_tree_and_head(&mut self) -> Result<()> { + self.range_select_anchor = None; + match self.context_mgr.active() { + ContextId::Commits + | ContextId::CommitFiles + | ContextId::Reflog + | ContextId::BranchCommits + | ContextId::BranchCommitFiles => { + self.context_mgr.set_active(ContextId::Files); + self.needs_diff_refresh = true; + } + _ => self.focus_head_commit()?, + } + Ok(()) + } + fn next_screen_mode(&mut self) { self.screen_mode = match self.screen_mode { ScreenMode::Normal => ScreenMode::Half, @@ -8905,6 +9295,7 @@ mod terminal_mouse_tests { old_segments: None, new_segments: None, file_header: None, + preview_placeholder: None, section_index: 0, }); cache.insert(format!("key-{index}"), view); @@ -8951,6 +9342,7 @@ mod terminal_mouse_tests { old_segments: None, new_segments: None, file_header: None, + preview_placeholder: None, section_index: 0, }); cache.insert(key.to_string(), view); @@ -8991,6 +9383,818 @@ mod terminal_mouse_tests { } assert_eq!(keys.len(), 8); } + + struct TempRepo { + path: PathBuf, + } + + impl TempRepo { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "lazygitrs-gui-test-{}-{}-{}", + name, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create temp dir"); + let _ = std::process::Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(&path) + .output(); + Self { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } + + fn git(&self, args: &[&str]) -> String { + let output = std::process::Command::new("git") + .args(["-c", "user.name=Test", "-c", "user.email=test@example.com"]) + .args(args) + .current_dir(&self.path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() + } + + fn commit(&self, path: &str, contents: &str) -> String { + std::fs::write(self.path.join(path), contents).unwrap(); + self.git(&["add", path]); + self.git(&["commit", "-m", contents]); + self.git(&["rev-parse", "HEAD"]) + } + } + + impl Drop for TempRepo { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + #[test] + fn tree_compare_navigation_folding_and_focus_preserve_current_layout() { + use crate::gui::modes::diff_mode::DiffModeFocus; + let repo = TempRepo::new("tree-compare"); + let mut gui = tree_test_gui(&repo); + gui.diff_mode.enter(true); + gui.diff_mode.diff_files = gui.model.lock().unwrap().commit_files.clone(); + gui.diff_mode.tree_nodes = gui.commit_file_tree_nodes.clone(); + for focus in [DiffModeFocus::CommitFiles, DiffModeFocus::DiffExploration] { + gui.diff_mode.set_focus(focus); + gui.diff_mode.diff_files_selected = 0; + gui.diff_mode.viewport_manually_scrolled = true; + gui.needs_diff_refresh = false; + gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.diff_mode.diff_files_selected, 1); + assert!(!gui.diff_mode.viewport_manually_scrolled); + assert!(gui.needs_diff_refresh); + gui.handle_key(KeyEvent::new(KeyCode::Char('>'), KeyModifiers::SHIFT)) + .unwrap(); + assert_eq!(gui.diff_mode.diff_files_selected, 4); + gui.handle_key(KeyEvent::new(KeyCode::Char('<'), KeyModifiers::SHIFT)) + .unwrap(); + assert_eq!(gui.diff_mode.diff_files_selected, 1); + } + for mode in [ScreenMode::Normal, ScreenMode::Half, ScreenMode::Full] { + gui.screen_mode = mode; + gui.diff_mode.set_focus(DiffModeFocus::CommitFiles); + gui.diff_mode.diff_files_selected = 0; + gui.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::DiffExploration); + assert_eq!(gui.screen_mode, mode); + gui.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::CommitFiles); + assert_eq!(gui.screen_mode, mode); + } + gui.handle_key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.diff_mode.tree_nodes.len(), 1); + gui.handle_key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.diff_mode.tree_nodes.len(), 5); + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .keybinding + .universal + .tree_child = "".into(); + gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.diff_mode.diff_files_selected, 0); + gui.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(gui.diff_mode.diff_files_selected, 1); + } + + #[test] + fn tree_commit_details_conflict_is_limited_to_active_tree_bindings() { + let repo = TempRepo::new("tree-details"); + let mut gui = tree_test_gui(&repo); + gui.context_mgr.set_active(ContextId::CommitFiles); + let details = gui.show_commit_details; + gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.context_mgr.selected_active(), 1); + assert_eq!(gui.show_commit_details, details); + // Other tree actions remapped to apostrophe also take precedence, even at a boundary. + let kb = &mut Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .keybinding; + kb.universal.tree_child.clear(); + kb.universal.tree_parent = "'".into(); + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.context_mgr.selected_active(), 0); + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.show_commit_details, details); + gui.show_commit_file_tree = false; + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) + .unwrap(); + assert_ne!(gui.show_commit_details, details); + } + + #[test] + fn tree_default_child_key_preserves_commit_details_toggle() { + let repo = TempRepo::new("tree-default-details"); + let mut gui = tree_test_gui(&repo); + for ctx in [ContextId::CommitFiles, ContextId::BranchCommitFiles] { + for tree in [false, true] { + gui.context_mgr.set_active(ctx); + gui.context_mgr.set_selection(0); + gui.show_commit_file_tree = tree; + let details = gui.show_commit_details; + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) + .unwrap(); + assert_ne!(gui.show_commit_details, details, "{ctx:?} {tree}"); + assert_eq!(gui.context_mgr.selected_active(), 0); + gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.context_mgr.selected_active(), usize::from(tree)); + assert_ne!(gui.show_commit_details, details); + } + } + } + + #[test] + fn apostrophe_toggles_details_and_is_documented_in_commit_context_help() { + let repo = TempRepo::new("apostrophe-details-help"); + let mut gui = tree_test_gui(&repo); + for ctx in [ + ContextId::Commits, + ContextId::BranchCommits, + ContextId::Reflog, + ContextId::CommitFiles, + ContextId::BranchCommitFiles, + ContextId::StashFiles, + ] { + gui.popup = PopupState::None; + gui.context_mgr.set_active(ctx); + let details = gui.show_commit_details; + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) + .unwrap(); + assert_ne!(gui.show_commit_details, details, "{ctx:?}"); + gui.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)) + .unwrap(); + let PopupState::CommandPalette { sections, .. } = &gui.popup else { + panic!("expected help palette"); + }; + let details_entries: Vec<_> = sections + .iter() + .flat_map(|s| &s.entries) + .filter(|e| e.description == "Toggle commit details panel") + .collect(); + // Stash-file help did not previously include a details entry. + if ctx != ContextId::StashFiles { + assert_eq!(details_entries.len(), 1, "{ctx:?}"); + assert_eq!(details_entries[0].key, "'", "{ctx:?}"); + } + } + } + + #[test] + fn tree_help_is_only_available_in_active_tree_contexts() { + use crate::gui::modes::diff_mode::DiffModeFocus; + let repo = TempRepo::new("tree-contextual-help"); + let mut gui = tree_test_gui(&repo); + for ctx in [ + ContextId::Files, + ContextId::CommitFiles, + ContextId::BranchCommitFiles, + ContextId::StashFiles, + ContextId::Commits, + ] { + for tree in [false, true] { + for diff in [false, true] { + gui.popup = PopupState::None; + gui.context_mgr.set_active(ctx); + gui.show_file_tree = tree; + gui.show_commit_file_tree = tree; + gui.diff_focused = diff; + gui.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)) + .unwrap(); + let PopupState::CommandPalette { sections, .. } = &gui.popup else { + panic!("expected help palette"); + }; + let tree_section = sections.iter().find(|s| s.title == "File Tree"); + assert_eq!( + tree_section.is_some(), + tree && ctx != ContextId::Commits, + "{ctx:?} tree={tree} diff={diff}" + ); + if let Some(section) = tree_section { + assert!( + section + .entries + .iter() + .any(|e| e.key == "." + && e.description == "Select first visible child") + ); + assert_eq!(section.entries.iter().any(|e| e.key == "-"), !diff); + } + } + } + } + gui.diff_mode.enter(true); + for tree in [false, true] { + for focus in [ + DiffModeFocus::CommitFiles, + DiffModeFocus::DiffExploration, + DiffModeFocus::Commits, + DiffModeFocus::SelectorA, + DiffModeFocus::SelectorB, + ] { + gui.popup = PopupState::None; + gui.diff_mode.show_tree = tree; + gui.diff_mode.set_focus(focus); + gui.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)) + .unwrap(); + let PopupState::CommandPalette { sections, .. } = &gui.popup else { + panic!("expected compare help palette"); + }; + let tree_section = sections.iter().find(|s| s.title == "File Tree"); + assert_eq!( + tree_section.is_some(), + tree && matches!( + focus, + DiffModeFocus::CommitFiles | DiffModeFocus::DiffExploration + ) + ); + if let Some(section) = tree_section { + assert!(section.entries.iter().any(|e| e.key == ".")); + assert_eq!( + section.entries.iter().any(|e| e.key == "-"), + focus == DiffModeFocus::CommitFiles + ); + } + } + } + } + + #[test] + fn tree_fold_binding_does_not_override_flat_list_actions() { + use crate::gui::modes::diff_mode::DiffModeFocus; + let repo = TempRepo::new("tree-flat-fold"); + let mut gui = tree_test_gui(&repo); + // A remapped fold key must not swallow a flat list's tree-toggle action. + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .keybinding + .universal + .fold_directory = "`".into(); + for ctx in [ + ContextId::Files, + ContextId::CommitFiles, + ContextId::BranchCommitFiles, + ContextId::StashFiles, + ] { + gui.context_mgr.set_active(ctx); + gui.show_file_tree = false; + gui.show_commit_file_tree = false; + gui.handle_key(KeyEvent::new(KeyCode::Char('`'), KeyModifiers::NONE)) + .unwrap(); + assert!(gui.active_file_tree(), "{ctx:?}"); + } + gui.diff_mode.enter(false); + gui.diff_mode.set_focus(DiffModeFocus::CommitFiles); + gui.handle_key(KeyEvent::new(KeyCode::Char('`'), KeyModifiers::NONE)) + .unwrap(); + assert!(gui.diff_mode.show_tree); + } + + fn tree_test_gui(repo: &TempRepo) -> Gui { + std::fs::create_dir_all(repo.path.join("src/deep")).unwrap(); + repo.commit("src/deep/a.txt", "base\n"); + repo.commit("src/deep/b.txt", "base\n"); + repo.commit("top.txt", "base\n"); + for path in ["src/deep/a.txt", "src/deep/b.txt", "top.txt"] { + std::fs::write(repo.path.join(path), "changed\n").unwrap(); + } + let mut config = crate::config::AppConfig::default(); + config.state_path = repo.path.join("state.yml"); + let mut gui = Gui::new( + config, + crate::git::GitCommands::new(repo.path()).unwrap(), + None, + false, + ) + .unwrap(); + let files = gui.git.load_files().unwrap(); + gui.model.lock().unwrap().files = files; + gui.show_file_tree = true; + gui.show_commit_file_tree = true; + gui.update_file_tree_state(); + gui.model.lock().unwrap().commit_files = ["src/deep/a.txt", "src/deep/b.txt", "top.txt"] + .into_iter() + .map(|name| crate::model::CommitFile { + name: name.into(), + status: crate::model::FileChangeStatus::Modified, + hunk_count: 0, + additions: 0, + deletions: 0, + }) + .collect(); + controller::commit_files::update_commit_file_tree_state(&mut gui); + gui.diff_view.load("test.txt", "old\n", "new\n"); + gui + } + + #[test] + fn tree_enter_and_escape_preserve_all_screen_modes() { + let repo = TempRepo::new("tree-layout"); + let mut gui = tree_test_gui(&repo); + for ctx in [ + ContextId::Files, + ContextId::CommitFiles, + ContextId::BranchCommitFiles, + ContextId::StashFiles, + ] { + for mode in [ScreenMode::Normal, ScreenMode::Half, ScreenMode::Full] { + for tree in [false, true] { + gui.context_mgr.set_active(ctx); + gui.context_mgr.set_selection(0); + gui.show_file_tree = tree; + gui.show_commit_file_tree = tree; + gui.screen_mode = mode; + gui.diff_focused = false; + gui.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .unwrap(); + assert!(gui.diff_focused, "{ctx:?} {tree}"); + assert_eq!(gui.screen_mode, mode); + gui.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .unwrap(); + assert!(!gui.diff_focused); + assert_eq!(gui.screen_mode, mode); + assert_eq!(gui.context_mgr.active(), ctx); + } + } + } + } + + #[test] + fn tree_navigation_resumes_viewport_following_in_sidebar_and_diff() { + let repo = TempRepo::new("tree-navigation"); + let mut gui = tree_test_gui(&repo); + for ctx in [ + ContextId::Files, + ContextId::CommitFiles, + ContextId::BranchCommitFiles, + ContextId::StashFiles, + ] { + for diff in [false, true] { + gui.context_mgr.set_active(ctx); + gui.context_mgr.set_selection(0); + gui.diff_focused = diff; + gui.context_mgr.viewport_manually_scrolled = true; + gui.needs_diff_refresh = false; + gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.context_mgr.selected_active(), 1); + assert!(!gui.context_mgr.viewport_manually_scrolled); + assert!(gui.needs_diff_refresh); + gui.context_mgr.set_scroll_offset(ctx, 100); + gui.context_mgr.ensure_scroll_visible(ctx, 2); + assert!(gui.context_mgr.scroll_offset(ctx) <= 1); + gui.handle_key(KeyEvent::new(KeyCode::Char('>'), KeyModifiers::SHIFT)) + .unwrap(); + assert_eq!(gui.context_mgr.selected_active(), 4); + gui.handle_key(KeyEvent::new(KeyCode::Char('<'), KeyModifiers::SHIFT)) + .unwrap(); + assert_eq!(gui.context_mgr.selected_active(), 1); + gui.handle_key(KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.context_mgr.selected_active(), 0); + } + } + } + + #[test] + fn tree_custom_and_disabled_keys_work_without_swallowing_flat_diff_keys() { + let repo = TempRepo::new("tree-custom-bindings"); + let mut gui = tree_test_gui(&repo); + for diff in [false, true] { + gui.diff_focused = diff; + gui.context_mgr.set_selection(0); + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .keybinding + .universal + .tree_child = "".into(); + let details = gui.show_commit_details; + gui.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(gui.context_mgr.selected_active(), 1); + assert_eq!(gui.show_commit_details, details); + gui.context_mgr.set_selection(0); + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .keybinding + .universal + .tree_child + .clear(); + gui.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(gui.context_mgr.selected_active(), 0); + } + // A remap to a diff shortcut must not claim that key outside tree contexts. + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .keybinding + .universal + .tree_child = "j".into(); + gui.show_file_tree = false; + gui.diff_focused = true; + gui.diff_view.load("long", "", &"line\n".repeat(100)); + gui.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.diff_view.scroll_offset, 1); + } + + #[test] + fn tree_fold_is_explicit_and_enter_does_not_collapse_directories() { + let repo = TempRepo::new("tree-fold"); + let mut gui = tree_test_gui(&repo); + for ctx in [ContextId::Files, ContextId::CommitFiles] { + gui.context_mgr.set_active(ctx); + gui.context_mgr.set_selection(0); + gui.diff_focused = false; + gui.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .unwrap(); + assert!(gui.diff_focused); + gui.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .unwrap(); + gui.handle_key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!( + if ctx == ContextId::Files { + gui.file_tree_nodes.len() + } else { + gui.commit_file_tree_nodes.len() + }, + 1 + ); + gui.handle_key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!( + if ctx == ContextId::Files { + gui.file_tree_nodes.len() + } else { + gui.commit_file_tree_nodes.len() + }, + 5 + ); + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .keybinding + .universal + .fold_directory = "_".into(); + let kb = gui.config.user_config.keybinding.clone(); + gui.handle_context_key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!( + if ctx == ContextId::Files { + gui.file_tree_nodes.len() + } else { + gui.commit_file_tree_nodes.len() + }, + 5 + ); + if ctx == ContextId::Files { + controller::files::handle_key( + &mut gui, + KeyEvent::new(KeyCode::Char('_'), KeyModifiers::SHIFT), + &kb, + ) + .unwrap(); + } else { + controller::commit_files::handle_key( + &mut gui, + KeyEvent::new(KeyCode::Char('_'), KeyModifiers::SHIFT), + &kb, + ) + .unwrap(); + } + assert_eq!( + if ctx == ContextId::Files { + gui.file_tree_nodes.len() + } else { + gui.commit_file_tree_nodes.len() + }, + 1 + ); + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .keybinding + .universal + .fold_directory = "-".into(); + } + } + + #[test] + fn toggle_working_tree_and_head_toggles_between_files_and_head() { + let repo = TempRepo::new("toggle-files-head"); + let config = crate::config::AppConfig::default(); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = Gui::new(config, git, None, false).unwrap(); + + // Starts in Files by default + assert_eq!(gui.context_mgr.active(), ContextId::Files); + + // Toggle to Commits (HEAD at index 0) + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Commits); + assert_eq!(gui.context_mgr.selected(ContextId::Commits), 0); + assert_eq!(gui.context_mgr.scroll_offset(ContextId::Commits), 0); + assert!(gui.needs_diff_refresh); + + // Move commit selection, then toggle back to Files + gui.context_mgr.set_selection_for(ContextId::Commits, 3); + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + assert!(gui.needs_diff_refresh); + + // Toggle from CommitFiles subcontext back to Files + gui.context_mgr.set_active(ContextId::CommitFiles); + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + + // Toggle from outside context (e.g. Branches) jumps to Commits at HEAD + gui.context_mgr.set_active(ContextId::Branches); + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Commits); + assert_eq!(gui.context_mgr.selected(ContextId::Commits), 0); + } + + #[test] + fn start_in_commits_initializes_context_to_commits_head() { + let repo = TempRepo::new("start-in-commits"); + let config = crate::config::AppConfig::default(); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let gui = Gui::new(config, git, None, true).unwrap(); + + assert_eq!(gui.context_mgr.active(), ContextId::Commits); + assert_eq!(gui.context_mgr.selected(ContextId::Commits), 0); + } + + #[test] + fn ctrl_g_key_event_triggers_toggle() { + let repo = TempRepo::new("ctrl-g-toggle"); + let config = crate::config::AppConfig::default(); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = Gui::new(config, git, None, false).unwrap(); + + assert_eq!(gui.context_mgr.active(), ContextId::Files); + + // Send Ctrl+g + let ctrl_g = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL); + gui.handle_key(ctrl_g).unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Commits); + + // Send Ctrl+g again to toggle back + gui.handle_key(ctrl_g).unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + + // Send Ctrl+g while diff is focused + gui.diff_focused = true; + gui.handle_key(ctrl_g).unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Commits); + assert!(gui.diff_focused); + + // The configured shortcut also toggles back while diff-focused. + gui.handle_key(ctrl_g).unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + assert!(gui.diff_focused); + + // Test custom keybinding (e.g. ) + let mut custom_config = crate::config::AppConfig::default(); + custom_config + .user_config + .keybinding + .universal + .toggle_working_tree_and_head = "".into(); + let git2 = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui2 = Gui::new(custom_config, git2, None, false).unwrap(); + let ctrl_t = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL); + gui2.handle_key(ctrl_t).unwrap(); + assert_eq!(gui2.context_mgr.active(), ContextId::Commits); + } + + #[test] + fn head_toggle_selects_checked_out_hash_not_another_branch_tip() { + let repo = TempRepo::new("head-not-first"); + let head = repo.commit("file", "main commit"); + repo.git(&["checkout", "-b", "ahead"]); + let other = repo.commit("file", "other branch commit"); + repo.git(&["checkout", "main"]); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let commits = git.load_commits(300).unwrap(); + assert_eq!(commits[0].hash, other); + let mut gui = Gui::new(crate::config::AppConfig::default(), git, None, false).unwrap(); + gui.model.lock().unwrap().set_commits(commits); + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Commits); + let index = gui.context_mgr.selected(ContextId::Commits); + assert_eq!(gui.model.lock().unwrap().commits[index].hash, head); + assert_eq!(gui.context_mgr.scroll_offset(ContextId::Commits), index); + } + + #[test] + fn head_toggle_preserves_filters_and_uses_separate_head_history() { + let repo = TempRepo::new("filtered-head"); + let older = repo.commit("filtered", "older"); + let head = repo.commit("other", "head"); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = Gui::new(crate::config::AppConfig::default(), git, None, false).unwrap(); + + for filter in [ + crate::git::commit::CommitFilter { + path: Some("filtered".into()), + ..Default::default() + }, + crate::git::commit::CommitFilter { + authors: vec!["nobody@example.com".into()], + ..Default::default() + }, + crate::git::commit::CommitFilter { + branches: vec![older.clone()], + ..Default::default() + }, + ] { + let commits = gui.git.load_filtered_commits_page(&filter, 300, 0).unwrap(); + let hashes: Vec<_> = commits.iter().map(|c| c.hash.clone()).collect(); + assert!(!hashes.contains(&head)); + gui.commit_path_filter = filter.path.clone(); + gui.commit_author_filter = filter.authors.clone(); + gui.commit_branch_filter = filter.branches.clone(); + gui.model.lock().unwrap().set_commits(commits); + gui.context_mgr.set_active(ContextId::Files); + gui.context_mgr.set_selection(2); + gui.diff_focused = true; + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::BranchCommits); + assert_eq!(gui.model.lock().unwrap().sub_commits[0].hash, head); + assert_eq!(gui.commit_path_filter, filter.path); + assert_eq!(gui.commit_author_filter, filter.authors); + assert_eq!(gui.commit_branch_filter, filter.branches); + assert_eq!( + gui.model + .lock() + .unwrap() + .commits + .iter() + .map(|c| c.hash.clone()) + .collect::>(), + hashes + ); + + // Streaming model refreshes do not retain sub-history themselves. + gui.model.lock().unwrap().clear_sub_commits(); + gui.after_model_refresh().unwrap(); + assert_eq!(gui.model.lock().unwrap().sub_commits[0].hash, head); + gui.context_mgr.set_active(ContextId::BranchCommitFiles); + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + assert_eq!(gui.context_mgr.selected(ContextId::Files), 2); + assert!(gui.diff_focused); + } + } + + #[test] + fn head_toggle_handles_head_outside_the_loaded_page_and_detached_head() { + let repo = TempRepo::new("paged-head"); + let older = repo.commit("file", "older"); + let head = repo.commit("file", "head"); + repo.git(&["checkout", "--detach", &head]); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let commits = git.load_commits_for_branch(&older, 1).unwrap(); + let mut gui = Gui::new(crate::config::AppConfig::default(), git, None, false).unwrap(); + gui.model.lock().unwrap().set_commits(commits); + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::BranchCommits); + assert_eq!(gui.model.lock().unwrap().sub_commits[0].hash, head); + assert_eq!(gui.model.lock().unwrap().commits[0].hash, older); + } + + #[test] + fn head_toggle_does_not_consume_old_binding_when_remapped_or_disabled() { + let repo = TempRepo::new("remapped-head"); + repo.commit("file", "head"); + for binding in ["", ""] { + for diff_focused in [false, true] { + let mut config = crate::config::AppConfig::default(); + config + .user_config + .keybinding + .universal + .toggle_working_tree_and_head = binding.into(); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = Gui::new(config, git, None, false).unwrap(); + gui.diff_focused = diff_focused; + gui.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + gui.popup = PopupState::None; + gui.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!( + gui.context_mgr.active(), + if binding.is_empty() { + ContextId::Files + } else { + ContextId::BranchCommits + } + ); + } + } + } + #[test] + fn commits_startup_selects_actual_head_and_preserves_path_filter() { + let repo = TempRepo::new("startup-head"); + repo.commit("filtered", "older"); + let head = repo.commit("other", "head"); + repo.git(&["checkout", "-b", "ahead"]); + repo.commit("other", "ahead"); + repo.git(&["checkout", "main"]); + for path in [None, Some(PathBuf::from("filtered"))] { + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = + Gui::new(crate::config::AppConfig::default(), git, path.clone(), true).unwrap(); + let commits = gui + .git + .load_filtered_commits_page( + &gui.commit_filter_for_load().unwrap_or_default(), + 300, + 0, + ) + .unwrap(); + gui.model.lock().unwrap().set_commits(commits); + gui.select_startup_head().unwrap(); + assert!(!gui.pending_startup_head); + let model = gui.model.lock().unwrap(); + let selected = gui.context_mgr.selected_active(); + let commit = if path.is_some() { + &model.sub_commits[selected] + } else { + &model.commits[selected] + }; + assert_eq!(commit.hash, head); + assert_eq!( + gui.commit_path_filter, + path.map(|p| p.to_string_lossy().to_string()) + ); + } + } + + #[test] + fn startup_head_does_not_override_navigation() { + let repo = TempRepo::new("startup-navigation"); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = Gui::new(crate::config::AppConfig::default(), git, None, true).unwrap(); + gui.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + gui.select_startup_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + } } fn setup_terminal() -> Result<(Term, bool)> { @@ -9006,6 +10210,8 @@ fn setup_terminal() -> Result<(Term, bool)> { crossterm::event::EnableBracketedPaste, cursor::Hide )?; + // Detect graphics before Crossterm creates its internal response reader. + crate::pager::image_preview::initialize(); // Helix leaves progressive kitty keyboard enhancement enabled across // `:insert-output` and keeps a `/dev/tty` EventStream open. Probing races // that reader (blank hang); instead pop leftover stacks and push our flags @@ -9035,6 +10241,7 @@ fn setup_terminal() -> Result<(Term, bool)> { /// process-wide mutex that the input thread holds for the duration of its /// blocking read, so any drain from this thread would silently no-op. fn restore_terminal(terminal: &mut Term, keyboard_enhanced: bool) -> Result<()> { + crate::pager::image_preview::flush_cleanup(terminal.backend_mut()); // Helix `:insert-output` keeps its own alt-screen / raw mode / mouse / focus // / bracketed-paste / kitty stack across the child. If we tear those down // here, Helix resumes drawing into a world that no longer exists and the diff --git a/src/gui/modes/diff_mode.rs b/src/gui/modes/diff_mode.rs index 10d48b5..ed59637 100644 --- a/src/gui/modes/diff_mode.rs +++ b/src/gui/modes/diff_mode.rs @@ -6,25 +6,511 @@ pub enum DiffModeFocus { SelectorA, SelectorB, CommitFiles, + Commits, DiffExploration, } +/// The diff being previewed remains stable when focus moves to the diff panel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompareDiffSource { + Comparison, + Commit, + CommitFiles, +} + +/// Shared by rendering, mouse hit-testing, and diff text-selection geometry. +pub struct CompareLayout { + pub sidebar: [ratatui::layout::Rect; 4], + pub diff: ratatui::layout::Rect, + pub status: ratatui::layout::Rect, + pub portrait: bool, +} + +impl CompareLayout { + pub fn new( + area: ratatui::layout::Rect, + side_ratio: f64, + screen_mode: crate::gui::ScreenMode, + state: &DiffModeState, + ) -> Self { + use crate::gui::ScreenMode; + use ratatui::layout::{Constraint, Direction, Layout, Rect}; + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(area); + let content = outer[0]; + let portrait = + crate::gui::layout::should_use_portrait(area.width, area.height, screen_mode); + let mut result = Self { + sidebar: [Rect::default(); 4], + diff: Rect::default(), + status: outer[1], + portrait, + }; + if screen_mode == ScreenMode::Full { + if state.focus == DiffModeFocus::DiffExploration { + result.diff = content; + } else { + let index = state.focus.sidebar_index().unwrap(); + // Keep ref inputs compact with room below for their dropdowns. + result.sidebar[index] = if index < 2 { + Rect { + height: content.height.min(3), + ..content + } + } else { + content + }; + } + return result; + } + let ratio = if screen_mode == ScreenMode::Half { + 0.5 + } else { + side_ratio.clamp(0.0, 1.0) + }; + let direction = if portrait { + Direction::Vertical + } else { + Direction::Horizontal + }; + let extent = if portrait { + content.height + } else { + content.width + }; + let side_extent = (f64::from(extent) * ratio).round() as u16; + let split = Layout::default() + .direction(direction) + .constraints([Constraint::Length(side_extent), Constraint::Min(0)]) + .split(content); + result.diff = split[1]; + let constraints = if portrait { + // Like the main view, collapse inactive panels to title rows when + // stacked above the diff, giving the last-focused list the space. + let active = state.sidebar_focus.sidebar_index().unwrap_or(2); + std::array::from_fn(|i| { + if i == active { + if i < 2 { + Constraint::Length(3) + } else { + Constraint::Min(1) + } + } else if active < 2 && i == 2 { + Constraint::Min(1) + } else { + Constraint::Length(1) + } + }) + } else { + [ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Fill(1), + ] + }; + result.sidebar.copy_from_slice( + &Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(split[0]), + ); + result + } + + /// Both borders of the actual side/diff split can start a resize. At + /// either extreme, the outermost border allows the hidden pane to reopen. + pub fn divider_hit(&self, content: ratatui::layout::Rect, col: u16, row: u16) -> bool { + if content.width == 0 || content.height == 0 || !content.contains((col, row).into()) { + return false; + } + if self.portrait { + let split = self + .sidebar + .iter() + .map(|r| r.bottom()) + .max() + .unwrap_or(content.y); + row == split.min(content.bottom().saturating_sub(1)) + || (split > content.y && row == split - 1) + } else { + let split = self.diff.x; + col == split.min(content.right().saturating_sub(1)) + || (split > content.x && col == split - 1) + } + } + + pub fn resize_row_offset(&self, row: u16) -> u16 { + if self.portrait && self.diff.height > 0 && row < self.diff.y { + 1 + } else { + 0 + } + } +} + +impl DiffModeState { + pub fn clear_commits(&mut self) { + self.commits.clear(); + self.commits_selected = 0; + self.commits_scroll = 0; + self.commits_viewport_manually_scrolled = false; + self.commits_revision = self.commits_revision.wrapping_add(1); + self.diff_source = CompareDiffSource::Comparison; + self.files_commit = None; + self.clear_list_search(); + } + + pub fn clear_list_search(&mut self) { + self.file_search_active = false; + self.file_search_query.clear(); + self.file_search_matches.clear(); + self.file_search_match_idx = 0; + self.file_search_textarea = None; + } + + pub fn set_focus(&mut self, focus: DiffModeFocus) { + if (self.focus == DiffModeFocus::Commits) != (focus == DiffModeFocus::Commits) { + self.clear_list_search(); + } + self.focus = focus; + if focus != DiffModeFocus::DiffExploration { + self.sidebar_focus = focus; + } + match focus { + DiffModeFocus::Commits => self.diff_source = CompareDiffSource::Commit, + DiffModeFocus::CommitFiles => { + self.diff_source = if self.files_commit.is_some() { + CompareDiffSource::CommitFiles + } else { + CompareDiffSource::Comparison + }; + } + _ => {} + } + } + + pub fn selected_commit(&self) -> Option<&Commit> { + self.commits.get(self.commits_selected) + } + + pub fn has_more_commits(&self) -> bool { + self.ahead_behind + .is_some_and(|(ahead, behind)| self.commits.len() < ahead + behind) + } + + pub fn select_list_match(&mut self, index: usize) { + if self.focus == DiffModeFocus::Commits { + self.commits_selected = index; + self.commits_viewport_manually_scrolled = false; + } else { + self.diff_files_selected = index; + self.viewport_manually_scrolled = false; + } + } + + pub fn diff_key(&self) -> String { + if self.diff_source == CompareDiffSource::Commit { + return format!( + "DiffMode:commit:{}", + self.selected_commit() + .map(|c| c.hash.as_str()) + .unwrap_or("none") + ); + } + let item = if self.show_tree { + self.tree_nodes.get(self.diff_files_selected).map(|node| { + node.file_index + .and_then(|i| self.diff_files.get(i)) + .map(|file| format!("file:{}", file.name)) + .unwrap_or_else(|| format!("dir:{}", node.path)) + }) + } else { + self.diff_files + .get(self.diff_files_selected) + .map(|file| format!("file:{}", file.name)) + } + .unwrap_or_else(|| "none".into()); + if self.diff_source == CompareDiffSource::CommitFiles { + format!( + "DiffMode:commit-files:{}:{item}", + self.files_commit.as_deref().unwrap_or("none") + ) + } else { + format!("DiffMode:{}..{}:{item}", self.ref_a, self.ref_b) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn commit(hash: &str, name: &str) -> Commit { + Commit { + hash: hash.into(), + name: name.into(), + status: crate::model::CommitStatus::Pushed, + action: String::new(), + tags: vec![], + refs: vec![], + extra_info: String::new(), + author_name: "Test".into(), + author_email: String::new(), + unix_timestamp: 0, + parents: vec![], + divergence: crate::model::commit::Divergence::Left, + } + } + + #[test] + fn compare_four_focuses_commits_and_tab_visits_all_five_panels() { + let panels = [ + DiffModeFocus::SelectorA, + DiffModeFocus::SelectorB, + DiffModeFocus::CommitFiles, + DiffModeFocus::Commits, + DiffModeFocus::DiffExploration, + ]; + for (i, panel) in panels.iter().enumerate() { + assert_eq!(DiffModeFocus::from_number(i as u32 + 1), Some(*panel)); + assert_eq!(panel.next(), panels[(i + 1) % panels.len()]); + assert_eq!(panel.prev(), panels[(i + panels.len() - 1) % panels.len()]); + assert_eq!(panel.next().prev(), *panel); + } + assert_eq!(DiffModeFocus::from_number(6), None); + } + + #[test] + fn compare_layout_modes_ratios_and_responsive_threshold_match_main_view() { + use crate::gui::ScreenMode; + use ratatui::layout::Rect; + let mut state = DiffModeState::new(); + state.set_focus(DiffModeFocus::Commits); + for (width, height, portrait) in [ + (150, 40, false), + (84, 26, true), + (85, 26, false), + (84, 25, false), + ] { + let area = Rect::new(5, 7, width, height); + for mode in [ScreenMode::Normal, ScreenMode::Half] { + let layout = CompareLayout::new(area, 0.4, mode, &state); + assert_eq!(layout.portrait, portrait); + assert_eq!(layout.status, Rect::new(5, 7 + height - 1, width, 1)); + let ratio = if mode == ScreenMode::Half { 0.5 } else { 0.4 }; + if portrait { + assert_eq!(layout.diff.x, area.x); + assert_eq!(layout.diff.width, width); + assert_eq!( + layout.diff.y - area.y, + (f64::from(height - 1) * ratio).round() as u16 + ); + assert_eq!(layout.sidebar[2].height, 1); + assert!(layout.sidebar[3].height > 1); + state.set_focus(DiffModeFocus::DiffExploration); + assert_eq!( + CompareLayout::new(area, 0.4, mode, &state).sidebar, + layout.sidebar + ); + state.set_focus(DiffModeFocus::Commits); + } else { + assert_eq!( + layout.diff.x - area.x, + (f64::from(width) * ratio).round() as u16 + ); + assert_eq!(layout.diff.y, area.y); + } + } + for focus in [ + DiffModeFocus::SelectorA, + DiffModeFocus::SelectorB, + DiffModeFocus::CommitFiles, + DiffModeFocus::Commits, + DiffModeFocus::DiffExploration, + ] { + state.set_focus(focus); + let layout = CompareLayout::new(area, 0.4, ScreenMode::Full, &state); + assert!(!layout.portrait); + if let Some(index) = focus.sidebar_index() { + assert!(layout.diff.is_empty()); + assert_eq!(layout.sidebar[index].width, width); + for i in 0..4 { + assert_eq!(layout.sidebar[i].is_empty(), i != index); + } + } else { + assert!(layout.sidebar.iter().all(|r| r.is_empty())); + assert_eq!(layout.diff, Rect::new(5, 7, width, height - 1)); + } + } + state.set_focus(DiffModeFocus::Commits); + } + } + + #[test] + fn compare_divider_hits_only_split_borders_and_reopens_collapsed_panes() { + use crate::gui::ScreenMode; + use ratatui::layout::Rect; + let state = DiffModeState::new(); + for (width, height) in [(150, 40), (80, 40)] { + let area = Rect::new(0, 0, width, height); + let content = Rect::new(0, 0, width, height - 1); + for ratio in [0.0, 0.4, 1.0] { + let layout = CompareLayout::new(area, ratio, ScreenMode::Normal, &state); + let split = if layout.portrait { + layout.diff.y + } else { + layout.diff.x + }; + let end = if layout.portrait { + content.height + } else { + content.width + }; + let border = split.min(end - 1); + let (col, row) = if layout.portrait { + (5, border) + } else { + (border, 5) + }; + assert!(layout.divider_hit(content, col, row)); + assert!(!layout.divider_hit(content, col, height - 1)); + if ratio == 0.4 { + let (col, row) = if layout.portrait { + (5, split - 2) + } else { + (split - 2, 5) + }; + assert!(!layout.divider_hit(content, col, row)); + } + } + } + } + + #[test] + fn compare_commit_preview_and_files_have_independent_diff_keys() { + let mut state = DiffModeState::new(); + state.ref_a = "main".into(); + state.ref_b = "feature".into(); + state.commits = vec![commit("a", "first"), commit("b", "second")]; + let comparison_key = state.diff_key(); + state.set_focus(DiffModeFocus::Commits); + assert_eq!(state.diff_key(), "DiffMode:commit:a"); + state.commits_selected = 1; + state.set_focus(DiffModeFocus::DiffExploration); + assert_eq!(state.diff_key(), "DiffMode:commit:b"); + state.set_focus(DiffModeFocus::CommitFiles); + assert_eq!(state.diff_key(), comparison_key); + state.files_commit = Some("b".into()); + state.set_focus(DiffModeFocus::CommitFiles); + let commit_file_key = state.diff_key(); + assert_ne!(commit_file_key, comparison_key); + state.set_focus(DiffModeFocus::DiffExploration); + assert_eq!(state.diff_key(), commit_file_key); + } + + #[test] + fn compare_commit_search_targets_commits_and_clears_on_focus_change() { + let mut state = DiffModeState::new(); + state.commits = vec![commit("a", "first"), commit("b", "second")]; + state.set_focus(DiffModeFocus::Commits); + state.file_search_query = "second".into(); + state.update_file_search_matches(); + assert_eq!(state.file_search_matches, vec![1]); + assert_eq!(state.commits_selected, 1); + assert_eq!(state.diff_files_selected, 0); + state.set_focus(DiffModeFocus::CommitFiles); + assert!(state.file_search_query.is_empty()); + assert!(state.file_search_matches.is_empty()); + } + + #[test] + fn compare_ref_changes_clear_commit_selection_files_and_search() { + let mut state = DiffModeState::new(); + state.commits = vec![commit("a", "first")]; + state.files_commit = Some("a".into()); + state.commits_scroll = 5; + state.file_search_query = "stale".into(); + state.start_editing(DiffModeSelector::A); + state.textarea.as_mut().unwrap().insert_str("HEAD"); + state.confirm_selection(); + assert!(state.commits.is_empty()); + assert!(state.files_commit.is_none()); + assert_eq!(state.commits_scroll, 0); + assert!(state.file_search_query.is_empty()); + assert_eq!(state.diff_source, CompareDiffSource::Comparison); + } + + #[test] + fn compare_counts_reverse_when_swapping_refs() { + let mut state = DiffModeState::new(); + state.ref_a = "main".into(); + state.ref_b = "feature".into(); + state.ahead_behind = Some((1, 3)); + state.swap_refs(); + assert_eq!(state.ref_a, "feature"); + assert_eq!(state.ref_b, "main"); + assert_eq!(state.ahead_behind, Some((3, 1))); + } + + #[test] + fn compare_counts_clear_when_selecting_refs_or_reentering() { + let mut state = DiffModeState::new(); + state.ahead_behind = Some((2, 1)); + state.start_editing(DiffModeSelector::A); + state.textarea.as_mut().unwrap().insert_str("HEAD"); + state.confirm_selection(); + assert_eq!(state.ref_a, "HEAD"); + assert_eq!(state.ahead_behind, None); + state.ahead_behind = Some((2, 1)); + state.exit(); + assert_eq!(state.ahead_behind, None); + state.ahead_behind = Some((2, 1)); + state.enter(false); + assert_eq!(state.ahead_behind, None); + } +} + impl DiffModeFocus { pub fn from_number(n: u32) -> Option { match n { 1 => Some(Self::SelectorA), 2 => Some(Self::SelectorB), 3 => Some(Self::CommitFiles), - 4 => Some(Self::DiffExploration), + 4 => Some(Self::Commits), + 5 => Some(Self::DiffExploration), _ => None, } } + pub fn sidebar_index(self) -> Option { + match self { + Self::SelectorA => Some(0), + Self::SelectorB => Some(1), + Self::CommitFiles => Some(2), + Self::Commits => Some(3), + Self::DiffExploration => None, + } + } + + pub fn prev(&self) -> Self { + match self { + Self::SelectorA => Self::DiffExploration, + Self::SelectorB => Self::SelectorA, + Self::CommitFiles => Self::SelectorB, + Self::Commits => Self::CommitFiles, + Self::DiffExploration => Self::Commits, + } + } + pub fn next(&self) -> Self { match self { Self::SelectorA => Self::SelectorB, Self::SelectorB => Self::CommitFiles, - Self::CommitFiles => Self::DiffExploration, + Self::CommitFiles => Self::Commits, + Self::Commits => Self::DiffExploration, Self::DiffExploration => Self::SelectorA, } } @@ -64,6 +550,16 @@ pub struct DiffModeState { pub ref_b: String, pub ref_a_display: String, pub ref_b_display: String, + /// Commits unique to A and B. None when refs aren't both commit-ish. + pub ahead_behind: Option<(usize, usize)>, + pub commits: Vec, + pub commits_selected: usize, + pub commits_scroll: usize, + pub commits_viewport_manually_scrolled: bool, + pub commits_revision: u64, + pub diff_source: CompareDiffSource, + /// When inspecting a commit's files, the file list is relative to its first parent. + pub files_commit: Option, // Combobox editing state pub editing: Option, @@ -74,6 +570,8 @@ pub struct DiffModeState { // Focus pub focus: DiffModeFocus, + /// Retain the expanded portrait list while inspecting its diff. + pub sidebar_focus: DiffModeFocus, // Commit files for A..B diff pub diff_files: Vec, @@ -103,12 +601,21 @@ impl DiffModeState { ref_b: String::new(), ref_a_display: String::new(), ref_b_display: String::new(), + ahead_behind: None, + commits: Vec::new(), + commits_selected: 0, + commits_scroll: 0, + commits_viewport_manually_scrolled: false, + commits_revision: 0, + diff_source: CompareDiffSource::Comparison, + files_commit: None, editing: None, textarea: None, search_results: Vec::new(), search_selected: 0, dropdown_scroll: 0, focus: DiffModeFocus::SelectorA, + sidebar_focus: DiffModeFocus::SelectorA, diff_files: Vec::new(), diff_files_selected: 0, diff_files_scroll: 0, @@ -125,17 +632,19 @@ impl DiffModeState { } pub fn enter(&mut self, show_tree: bool) { + self.clear_commits(); self.active = true; self.ref_a.clear(); self.ref_b.clear(); self.ref_a_display.clear(); self.ref_b_display.clear(); + self.ahead_behind = None; self.editing = None; self.textarea = None; self.search_results.clear(); self.search_selected = 0; self.dropdown_scroll = 0; - self.focus = DiffModeFocus::SelectorA; + self.set_focus(DiffModeFocus::SelectorA); self.diff_files.clear(); self.diff_files_selected = 0; self.diff_files_scroll = 0; @@ -151,7 +660,9 @@ impl DiffModeState { } pub fn exit(&mut self) { + self.clear_commits(); self.active = false; + self.ahead_behind = None; self.editing = None; self.textarea = None; self.search_results.clear(); @@ -161,8 +672,10 @@ impl DiffModeState { } pub fn swap_refs(&mut self) { + self.clear_commits(); std::mem::swap(&mut self.ref_a, &mut self.ref_b); std::mem::swap(&mut self.ref_a_display, &mut self.ref_b_display); + self.ahead_behind = self.ahead_behind.map(|(ahead, behind)| (behind, ahead)); self.diff_files.clear(); self.diff_files_selected = 0; self.diff_files_scroll = 0; @@ -236,6 +749,8 @@ impl DiffModeState { } } + self.clear_commits(); + self.ahead_behind = None; self.editing = None; self.textarea = None; self.search_results.clear(); @@ -378,7 +893,16 @@ impl DiffModeState { let query = self.file_search_query.to_lowercase(); - if self.show_tree { + if self.focus == DiffModeFocus::Commits { + for (i, commit) in self.commits.iter().enumerate() { + if [&commit.hash, &commit.name, &commit.author_name] + .iter() + .any(|text| text.to_lowercase().contains(&query)) + { + self.file_search_matches.push(i); + } + } + } else if self.show_tree { for (i, node) in self.tree_nodes.iter().enumerate() { if node.path.to_lowercase().contains(&query) || node.name.to_lowercase().contains(&query) @@ -397,7 +921,7 @@ impl DiffModeState { // Auto-jump to first match if !self.file_search_matches.is_empty() { self.file_search_match_idx = 0; - self.diff_files_selected = self.file_search_matches[0]; + self.select_list_match(self.file_search_matches[0]); } } @@ -408,7 +932,7 @@ impl DiffModeState { } self.file_search_match_idx = (self.file_search_match_idx + 1) % self.file_search_matches.len(); - self.diff_files_selected = self.file_search_matches[self.file_search_match_idx]; + self.select_list_match(self.file_search_matches[self.file_search_match_idx]); } /// Go to previous file search match. @@ -421,6 +945,6 @@ impl DiffModeState { } else { self.file_search_match_idx -= 1; } - self.diff_files_selected = self.file_search_matches[self.file_search_match_idx]; + self.select_list_match(self.file_search_matches[self.file_search_match_idx]); } } diff --git a/src/gui/presentation/commit_details.rs b/src/gui/presentation/commit_details.rs index 3c08e9c..129be76 100644 --- a/src/gui/presentation/commit_details.rs +++ b/src/gui/presentation/commit_details.rs @@ -4,6 +4,7 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use super::text::plain_text; use crate::config::Theme; use crate::model::commit::{Commit, CommitStat, CommitStatus}; @@ -40,7 +41,7 @@ pub fn render_commit_details( Span::raw(" "), Span::styled("toggle ", Style::default().fg(theme.text_dimmed)), Span::styled( - ".", + "'", Style::default() .fg(theme.accent) .add_modifier(Modifier::BOLD), @@ -61,9 +62,9 @@ pub fn render_commit_details( return; } - let message = full_message.unwrap_or(&commit.name); - let co_authors = parse_co_authors(message); - let display_message = strip_co_author_trailers(message); + let message = plain_text(full_message.unwrap_or(&commit.name)); + let co_authors = parse_co_authors(&message); + let display_message = strip_co_author_trailers(&message); let mut lines: Vec = Vec::new(); @@ -72,7 +73,10 @@ pub fn render_commit_details( if !compact && !commit.author_email.is_empty() { lines.push(Line::from(vec![ Span::styled(" ✉ ", Style::default().fg(theme.text_dimmed)), - Span::styled(commit.author_email.clone(), Style::default().fg(theme.text)), + Span::styled( + plain_text(&commit.author_email), + Style::default().fg(theme.text), + ), ])); } @@ -253,13 +257,13 @@ fn strip_co_author_trailers(message: &str) -> String { } fn header_line<'a>(commit: &'a Commit, theme: &Theme) -> Line<'a> { - let initial = commit - .author_name + let author_name = plain_text(&commit.author_name); + let initial = author_name .chars() .next() .map(|c| c.to_ascii_uppercase()) .unwrap_or('?'); - let avatar_color = avatar_color_for(&commit.author_email, theme); + let avatar_color = avatar_color_for(&plain_text(&commit.author_email), theme); let date = format_date(commit.unix_timestamp); Line::from(vec![ @@ -272,7 +276,7 @@ fn header_line<'a>(commit: &'a Commit, theme: &Theme) -> Line<'a> { ), Span::raw(" "), Span::styled( - commit.author_name.clone(), + author_name, Style::default() .fg(theme.text_strong) .add_modifier(Modifier::BOLD), @@ -420,6 +424,48 @@ fn avatar_color_for(email: &str, theme: &Theme) -> ratatui::style::Color { mod tests { use super::*; + #[test] + fn top_right_toggle_hint_uses_apostrophe_in_both_layouts() { + use ratatui::{Terminal, backend::TestBackend}; + + let commit = Commit { + hash: "1234567890abcdef".into(), + name: "Test commit".into(), + status: CommitStatus::Pushed, + action: String::new(), + tags: vec![], + refs: vec![], + extra_info: String::new(), + author_name: "Test Author".into(), + author_email: String::new(), + unix_timestamp: 0, + parents: vec![], + divergence: Default::default(), + }; + for compact in [false, true] { + let mut terminal = Terminal::new(TestBackend::new(60, 10)).unwrap(); + terminal + .draw(|frame| { + render_commit_details( + frame, + frame.area(), + &commit, + None, + None, + &Theme::default(), + compact, + &mut 0, + ); + }) + .unwrap(); + let top: String = (0..60) + .map(|x| terminal.backend().buffer().cell((x, 0)).unwrap().symbol()) + .collect(); + assert!(top.ends_with(" toggle ' ┐"), "{top}"); + assert!(!top.contains("toggle ."), "{top}"); + } + } + #[test] fn parses_co_authored_by_trailers() { let msg = "Subject\n\nBody\n\nCo-authored-by: Ada Lovelace \nCo-Authored-By: Grace Hopper \n"; diff --git a/src/gui/presentation/commits.rs b/src/gui/presentation/commits.rs index 838b9a5..e57c952 100644 --- a/src/gui/presentation/commits.rs +++ b/src/gui/presentation/commits.rs @@ -5,14 +5,37 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::config::Theme; use crate::model::Model; -use crate::model::commit::{Commit, CommitStatus}; +use crate::model::commit::{Commit, CommitStatus, Divergence}; use super::graph; +use super::text::plain_text; #[derive(Default)] pub struct CommitListCache { commits: GraphLayoutCache, sub_commits: GraphLayoutCache, + compare_commits: GraphLayoutCache, +} + +pub fn render_compare_commit_list_window<'a>( + commits: &'a [Commit], + revision: u64, + theme: &Theme, + offset: usize, + visible_height: usize, + cache: &mut CommitListCache, +) -> Vec> { + cache.compare_commits.update(commits, revision); + render_commits_window( + commits, + "", + theme, + &[], + offset, + visible_height, + false, + &cache.compare_commits, + ) } #[derive(Default)] @@ -38,14 +61,14 @@ impl GraphLayoutCache { } } -pub fn render_sub_commit_list_window( - model: &Model, +pub fn render_sub_commit_list_window<'a>( + model: &'a Model, theme: &Theme, offset: usize, visible_height: usize, full: bool, cache: &mut CommitListCache, -) -> Vec> { +) -> Vec> { cache .sub_commits .update(&model.sub_commits, model.sub_commits_revision); @@ -61,15 +84,15 @@ pub fn render_sub_commit_list_window( ) } -pub fn render_commit_list_window( - model: &Model, +pub fn render_commit_list_window<'a>( + model: &'a Model, theme: &Theme, cherry_picked: &[String], offset: usize, visible_height: usize, full: bool, cache: &mut CommitListCache, -) -> Vec> { +) -> Vec> { cache.commits.update(&model.commits, model.commits_revision); render_commits_window( &model.commits, @@ -90,8 +113,8 @@ pub fn render_commit_list_window( /// same compact/expanded responsiveness as lazygit. /// - Fixed columns (hash/date/author) are padded to the visible window's max /// width so rows align like lazygit's `RenderDisplayStrings`. -fn render_commits_window( - commits: &[Commit], +fn render_commits_window<'a>( + commits: &'a [Commit], head_hash: &str, theme: &Theme, cherry_picked: &[String], @@ -99,7 +122,7 @@ fn render_commits_window( visible_height: usize, full: bool, graph_layout: &GraphLayoutCache, -) -> Vec> { +) -> Vec> { let visible: Vec<(usize, &Commit)> = commits .iter() .enumerate() @@ -121,19 +144,21 @@ fn render_commits_window( } }) .collect(); - let authors: Vec = visible + let authors: Vec<(String, Color)> = visible .iter() .map(|(_, c)| { - if full { - long_author(&c.author_name, 17) + let author = plain_text(&c.author_name); + let label = if full { + long_author(&author, 17) } else { - author_initials(&c.author_name) - } + author_initials(&author) + }; + (label, author_color(&author)) }) .collect(); let date_w = dates.iter().map(|s| s.width()).max().unwrap_or(0); - let author_w = authors.iter().map(|s| s.width()).max().unwrap_or(0); + let author_w = authors.iter().map(|(s, _)| s.width()).max().unwrap_or(0); visible .iter() @@ -141,7 +166,18 @@ fn render_commits_window( .map(|(vi, (i, commit))| { let graph_row = graph_layout.rows.get(*i); let is_head = commit.hash == *head_hash; - let mut spans: Vec> = Vec::new(); + let mut spans: Vec> = Vec::new(); + + match commit.divergence { + Divergence::Left => { + spans.push(Span::styled("A ", Style::default().fg(theme.accent))) + } + Divergence::Right => spans.push(Span::styled( + "B ", + Style::default().fg(theme.accent_secondary), + )), + Divergence::None => {} + } // Hash (8, lazygit default) — color by push status. let is_cherry_picked = cherry_picked.iter().any(|h| *h == commit.hash); @@ -172,10 +208,9 @@ fn render_commits_window( // Author — per-author color like lazygit's AuthorStyle. if author_w > 0 { - let color = author_color(&commit.author_name); spans.push(Span::styled( - pad_to(&authors[vi], author_w), - Style::default().fg(color), + pad_to(&authors[vi].0, author_w), + Style::default().fg(authors[vi].1), )); spans.push(Span::raw(" ")); } @@ -210,7 +245,7 @@ fn render_commits_window( // Message. spans.push(Span::styled( - commit.name.clone(), + plain_text(&commit.name), Style::default().fg(theme.text_strong), )); @@ -436,6 +471,79 @@ mod tests { assert_eq!(items.len(), 1); } + #[test] + fn history_views_strip_ansi_before_clipping_or_wrapping() { + use ratatui::{ + Terminal, + backend::TestBackend, + widgets::{Block, Borders, List}, + }; + + let mut original = commit("31fc03a0", None); + original.name = "Add Vike skill — \x1b[4mhttps://vike.dev/ai#skill\x1b[24m".into(); + original.author_name = "\x1b[4mBlankeos\x1b[24m".into(); + original.author_email = "\x1b[4mdev@example.com\x1b[24m".into(); + let mut clean = original.clone(); + clean.name = plain_text(&clean.name).into_owned(); + clean.author_name = plain_text(&clean.author_name).into_owned(); + clean.author_email = plain_text(&clean.author_email).into_owned(); + + for width in [24, 60, 120] { + for view in 0..5 { + let render = |entry: Commit| { + let mut model = Model::default(); + model.set_commits(vec![entry.clone(), commit("aaaaaaaa", None)]); + model.reflog_commits = model.commits.clone(); + let theme = Theme::default(); + let mut terminal = Terminal::new(TestBackend::new(width, 12)).unwrap(); + terminal + .draw(|frame| { + if view >= 3 { + super::super::commit_details::render_commit_details( + frame, + frame.area(), + &entry, + None, + (view == 4).then_some(entry.name.as_str()), + &theme, + false, + &mut 0, + ); + } else { + let items = if view == 2 { + super::super::reflog::render_reflog_list(&model, &theme) + } else { + render_commit_list_window( + &model, + &theme, + &[], + 0, + 2, + view == 1, + &mut CommitListCache::default(), + ) + }; + frame.render_widget( + List::new(items).block(Block::default().borders(Borders::ALL)), + frame.area(), + ); + } + }) + .unwrap(); + terminal.backend().buffer().clone() + }; + let actual = render(original.clone()); + assert_eq!(actual, render(clean.clone()), "width={width}, view={view}"); + assert!(actual.content.iter().all(|cell| { + !cell.symbol().chars().any(char::is_control) + && !cell.modifier.contains(Modifier::UNDERLINED) + })); + } + } + // Rendering must not change data later used by Git operations. + assert!(original.name.contains('\x1b')); + } + #[test] fn author_initials_match_lazygit() { assert_eq!(author_initials(""), ""); diff --git a/src/gui/presentation/diff_mode.rs b/src/gui/presentation/diff_mode.rs index ab61c81..e782f7a 100644 --- a/src/gui/presentation/diff_mode.rs +++ b/src/gui/presentation/diff_mode.rs @@ -1,12 +1,15 @@ use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; use crate::config::Theme; -use crate::gui::modes::diff_mode::{DiffModeFocus, DiffModeState, RefKind}; +use crate::gui::modes::diff_mode::{ + CompareDiffSource, CompareLayout, DiffModeFocus, DiffModeState, RefKind, +}; use crate::gui::presentation::commit_files::commit_file_status_display; +use crate::gui::presentation::commits::{CommitListCache, render_compare_commit_list_window}; use crate::gui::presentation::files::append_file_stats; use crate::model::file_tree::CommitFileTreeNode; use crate::pager::side_by_side::{self, DiffViewState}; @@ -14,6 +17,250 @@ use crate::pager::side_by_side::{self, DiffViewState}; /// Max items visible in the dropdown at once. const DROPDOWN_MAX_VISIBLE: usize = 10; +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Commit, CommitStatus, commit::Divergence}; + use ratatui::{Terminal, backend::TestBackend}; + + fn commit(hash: &str, name: &str, divergence: Divergence) -> Commit { + Commit { + hash: hash.into(), + name: name.into(), + status: CommitStatus::Pushed, + action: String::new(), + tags: vec![], + refs: vec![], + extra_info: String::new(), + author_name: "Test Author".into(), + author_email: String::new(), + unix_timestamp: 0, + parents: vec![], + divergence, + } + } + + fn buffer_text(terminal: &Terminal) -> String { + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer.cell((x, y)).unwrap().symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[test] + fn compare_commits_panel_shows_sides_and_default_commit_rows() { + let mut state = DiffModeState::new(); + state.ref_a = "main".into(); + state.ref_b = "feature".into(); + state.ahead_behind = Some((1, 1)); + state.commits = vec![ + commit("aaaaaaaa", "Main change", Divergence::Left), + commit("bbbbbbbb", "Feature change", Divergence::Right), + ]; + let mut cache = CommitListCache::default(); + let mut terminal = Terminal::new(TestBackend::new(60, 6)).unwrap(); + terminal + .draw(|frame| { + render_compare_commits( + frame, + frame.area(), + &mut state, + &Theme::default(), + &mut cache, + ) + }) + .unwrap(); + let text = buffer_text(&terminal); + assert!(text.contains("4 Commits · 1 ahead, 1 behind"), "{text}"); + assert!(text.contains("A aaaaaaaa"), "{text}"); + assert!(text.contains("B bbbbbbbb"), "{text}"); + assert!(text.contains("Main change")); + assert!(text.contains("Feature change")); + assert!(!text.contains("Enter: files")); + assert!(!text.contains("A-only / B-only")); + let bottom_border: String = (0..60) + .map(|x| terminal.backend().buffer().cell((x, 5)).unwrap().symbol()) + .collect(); + assert_eq!(bottom_border, format!("└{}┘", "─".repeat(58))); + } + + #[test] + fn compare_help_bar_shows_commit_actions_without_duplicate_counts() { + for (focus, counts, more) in [ + (DiffModeFocus::Commits, Some((3, 1)), true), + (DiffModeFocus::Commits, Some((0, 0)), false), + (DiffModeFocus::CommitFiles, Some((3, 1)), true), + (DiffModeFocus::Commits, None, false), + ] { + let mut state = DiffModeState::new(); + state.set_focus(focus); + state.ahead_behind = counts; + let mut terminal = Terminal::new(TestBackend::new(80, 1)).unwrap(); + terminal + .draw(|frame| { + render_status_bar( + frame, + frame.area(), + &state, + &DiffViewState::default(), + &Theme::default(), + &crate::config::KeybindingConfig::default(), + ) + }) + .unwrap(); + let text = buffer_text(&terminal); + assert!(!text.contains("A vs B:"), "{text}"); + assert!(!text.contains("ahead"), "{text}"); + assert!(!text.contains("behind"), "{text}"); + assert_eq!( + text.contains("Enter files"), + focus == DiffModeFocus::Commits, + "{text}" + ); + assert_eq!( + text.contains("PgDn more"), + focus == DiffModeFocus::Commits && more, + "{text}" + ); + } + } + + #[test] + fn compare_footer_shows_only_enabled_folding_in_active_tree_lists() { + for tree in [false, true] { + for focus in [ + DiffModeFocus::CommitFiles, + DiffModeFocus::DiffExploration, + DiffModeFocus::Commits, + DiffModeFocus::SelectorA, + DiffModeFocus::SelectorB, + ] { + for (binding, hint) in [("-", "-"), ("", "ctrl+f"), ("", "")] { + let mut kb = crate::config::KeybindingConfig::default(); + kb.universal.fold_directory = binding.into(); + let mut state = DiffModeState::new(); + state.show_tree = tree; + state.set_focus(focus); + let mut terminal = Terminal::new(TestBackend::new(240, 1)).unwrap(); + terminal + .draw(|frame| { + render_status_bar( + frame, + frame.area(), + &state, + &DiffViewState::default(), + &Theme::default(), + &kb, + ) + }) + .unwrap(); + let text = buffer_text(&terminal); + assert_eq!( + text.contains("fold/unfold"), + tree && focus == DiffModeFocus::CommitFiles && !binding.is_empty(), + "{text}" + ); + if tree && focus == DiffModeFocus::CommitFiles && !binding.is_empty() { + assert!(text.contains(&format!("{hint} fold/unfold")), "{text}"); + } + assert!(!text.contains("nav"), "{text}"); + assert!(!text.contains("siblings"), "{text}"); + assert!(!text.contains("details"), "{text}"); + assert!(text.contains("? help"), "{text}"); + } + } + } + } + + #[test] + fn compare_empty_commit_states_are_distinct() { + for (counts, expected) in [ + (Some((0, 0)), "Same commit"), + (None, "Commit history unavailable"), + ] { + let mut state = DiffModeState::new(); + state.ref_a = "A".into(); + state.ref_b = "B".into(); + state.ahead_behind = counts; + let mut terminal = Terminal::new(TestBackend::new(60, 5)).unwrap(); + terminal + .draw(|frame| { + render_compare_commits( + frame, + frame.area(), + &mut state, + &Theme::default(), + &mut CommitListCache::default(), + ) + }) + .unwrap(); + assert!(buffer_text(&terminal).contains(expected)); + } + } + + #[test] + fn compare_layout_renders_four_sidebar_panels_and_handles_small_terminals() { + use crate::gui::ScreenMode; + for (width, height) in [(180, 30), (80, 40), (80, 14), (30, 8), (5, 3), (1, 1)] { + for mode in [ScreenMode::Normal, ScreenMode::Half, ScreenMode::Full] { + for focus in [ + DiffModeFocus::SelectorA, + DiffModeFocus::SelectorB, + DiffModeFocus::CommitFiles, + DiffModeFocus::Commits, + DiffModeFocus::DiffExploration, + ] { + let mut state = DiffModeState::new(); + state.set_focus(focus); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| { + render( + frame, + &mut state, + &mut DiffViewState::default(), + &Theme::default(), + false, + false, + &mut CommitListCache::default(), + 1.0 / 3.0, + mode, + &crate::config::KeybindingConfig::default(), + ) + }) + .unwrap(); + let text = buffer_text(&terminal); + if width == 180 { + for (panel_focus, title) in [ + (DiffModeFocus::SelectorA, "1 A"), + (DiffModeFocus::SelectorB, "2 B"), + (DiffModeFocus::CommitFiles, "3 Files"), + (DiffModeFocus::Commits, "4 Commits"), + (DiffModeFocus::DiffExploration, "5 Diff"), + ] { + assert_eq!( + text.contains(title), + mode != ScreenMode::Full || focus == panel_focus, + "{mode:?} {focus:?}: {text}" + ); + } + } + if width == 80 && height == 40 && mode != ScreenMode::Full { + for title in ["1 A", "2 B", "3 Files", "4 Commits", "5 Diff"] { + assert!(text.contains(title), "{mode:?} {focus:?}: {text}"); + } + } + } + } + } + } +} + pub fn render( frame: &mut Frame, state: &mut DiffModeState, @@ -21,53 +268,46 @@ pub fn render( theme: &Theme, diff_loading: bool, diff_loading_show: bool, + commit_cache: &mut CommitListCache, + side_ratio: f64, + screen_mode: crate::gui::ScreenMode, + keybindings: &crate::config::KeybindingConfig, ) { - let area = frame.area(); - - // Overall layout: sidebar (left) | diff panel (right) | status bar at bottom - let outer = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(1)]) - .split(area); - - let content = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(33), Constraint::Percentage(67)]) - .split(outer[0]); - - // Left sidebar: [A selector (3 lines)] [B selector (3 lines)] [Commit Files (rest)] - let sidebar = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Min(1), - ]) - .split(content[0]); - - render_selector(frame, sidebar[0], state, DiffModeFocus::SelectorA, theme); - render_selector(frame, sidebar[1], state, DiffModeFocus::SelectorB, theme); - render_commit_files(frame, sidebar[2], state, theme); - - // Right panel: diff exploration - render_diff_panel( - frame, - content[1], - state, - diff_view, - theme, - diff_loading, - diff_loading_show, - ); + let layout = CompareLayout::new(frame.area(), side_ratio, screen_mode, state); + let sidebar = layout.sidebar; - // Text selection highlight overlay and tooltip (must be before popups/dropdowns) - crate::gui::views::render_selection_overlay(frame, diff_view, content[1], theme); + if !sidebar[0].is_empty() { + render_selector(frame, sidebar[0], state, DiffModeFocus::SelectorA, theme); + } + if !sidebar[1].is_empty() { + render_selector(frame, sidebar[1], state, DiffModeFocus::SelectorB, theme); + } + if !sidebar[2].is_empty() { + render_commit_files(frame, sidebar[2], state, theme); + } + if !sidebar[3].is_empty() { + render_compare_commits(frame, sidebar[3], state, theme, commit_cache); + } + + if !layout.diff.is_empty() { + render_diff_panel( + frame, + layout.diff, + state, + diff_view, + theme, + diff_loading, + diff_loading_show, + ); + // Selection overlay must be before popups/dropdowns. + crate::gui::views::render_selection_overlay(frame, diff_view, layout.diff, theme); + } // Status bar - render_status_bar(frame, outer[1], state, diff_view, theme); + render_status_bar(frame, layout.status, state, diff_view, theme, keybindings); // Render combobox dropdown overlay on top of the sidebar - if state.editing.is_some() { + if state.editing.is_some() && sidebar.iter().any(|r| !r.is_empty()) { render_dropdown(frame, sidebar, state, theme); } } @@ -79,9 +319,8 @@ fn render_selector( which: DiffModeFocus, theme: &Theme, ) { - let (is_a, focused, editing, display, number_label) = match which { + let (focused, editing, display, number_label) = match which { DiffModeFocus::SelectorA => ( - true, state.focus == DiffModeFocus::SelectorA, matches!( state.editing, @@ -91,7 +330,6 @@ fn render_selector( " 1 A ", ), DiffModeFocus::SelectorB => ( - false, state.focus == DiffModeFocus::SelectorB, matches!( state.editing, @@ -135,6 +373,86 @@ fn render_selector( } } +fn render_compare_commits( + frame: &mut Frame, + area: Rect, + state: &mut DiffModeState, + theme: &Theme, + cache: &mut CommitListCache, +) { + let border = if state.focus == DiffModeFocus::Commits { + theme.active_border + } else { + Style::default().fg(theme.text_dimmed) + }; + let summary = state + .ahead_behind + .map(|(ahead, behind)| format!("{ahead} ahead, {behind} behind")); + let title = summary + .map(|summary| format!(" 4 Commits · {summary} ")) + .unwrap_or_else(|| " 4 Commits ".into()); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border); + let inner = block.inner(area); + if state.commits.is_empty() { + let message = if !state.has_both_refs() { + "Select refs A and B to compare" + } else if state.ahead_behind == Some((0, 0)) { + "Same commit — no unique commits" + } else if state.ahead_behind.is_none() { + "Commit history unavailable for these refs" + } else { + "No commits loaded" + }; + frame.render_widget( + Paragraph::new(Span::styled( + format!(" {message}"), + Style::default().fg(theme.text_dimmed), + )) + .block(block), + area, + ); + return; + } + let height = inner.height as usize; + if height == 0 { + frame.render_widget(block, area); + return; + } + if !state.commits_viewport_manually_scrolled { + crate::gui::scroll::ensure_visible( + state.commits_selected, + &mut state.commits_scroll, + height, + ); + } + state.commits_scroll = state + .commits_scroll + .min(state.commits.len().saturating_sub(height)); + let items = render_compare_commit_list_window( + &state.commits, + state.commits_revision, + theme, + state.commits_scroll, + height, + cache, + ); + let selection = state + .commits_selected + .checked_sub(state.commits_scroll) + .filter(|&i| i < items.len()); + let mut list_state = ListState::default().with_selected(selection); + frame.render_stateful_widget( + List::new(items) + .block(block) + .highlight_style(theme.selected_line), + area, + &mut list_state, + ); +} + fn render_commit_files(frame: &mut Frame, area: Rect, state: &mut DiffModeState, theme: &Theme) { let focused = state.focus == DiffModeFocus::CommitFiles; let border = if focused { @@ -143,16 +461,20 @@ fn render_commit_files(frame: &mut Frame, area: Rect, state: &mut DiffModeState, Style::default().fg(theme.text_dimmed) }; let tree_indicator = if state.show_tree { " (tree)" } else { "" }; - let title = format!( - " 3 Commit Files ({}{}) ", - state.diff_files.len(), - tree_indicator - ); + let title = format!(" 3 Files ({}{}) ", state.diff_files.len(), tree_indicator); let block = Block::default() .title(title) .borders(Borders::ALL) .border_style(border); let content_width = area.width.saturating_sub(2) as usize; + let block = if let Some(hash) = &state.files_commit { + block.title_bottom(format!( + " {} · Esc: comparison ", + &hash[..8.min(hash.len())] + )) + } else { + block + }; if state.diff_files.is_empty() { let msg = if state.has_both_refs() { @@ -342,13 +664,15 @@ fn render_diff_panel( Style::default().fg(theme.text_dimmed) }; let block = Block::default() - .title(" 4 Diff ") + .title(" 5 Diff ") .borders(Borders::ALL) .border_style(border); let msg = if diff_loading_show { " Loading diff..." + } else if state.diff_source == CompareDiffSource::Commit { + " No patch for this commit" } else if !state.has_both_refs() || state.diff_files.is_empty() { - " Select a file to view diff" + " Select a file or commit to view diff" } else { "" }; @@ -364,6 +688,7 @@ fn render_status_bar( state: &DiffModeState, diff_view: &DiffViewState, theme: &Theme, + keybindings: &crate::config::KeybindingConfig, ) { // If search is active or has results, show search bar instead of hints if state.file_search_active { @@ -424,6 +749,7 @@ fn render_status_bar( return; } + let fold_key = crate::gui::views::format_key_hint(&keybindings.universal.fold_directory); let hints = if state.editing.is_some() { vec![("Enter", "select"), ("Esc", "cancel"), ("↑↓", "navigate")] } else { @@ -431,15 +757,26 @@ fn render_status_bar( side_by_side::DiffViewLayout::SideBySide => "unified view", side_by_side::DiffViewLayout::Unified => "split view", }; - vec![ + let mut hints = Vec::new(); + if state.focus == DiffModeFocus::Commits { + hints.push(("Enter", "files")); + if state.has_more_commits() { + hints.push(("PgDn", "more")); + } + } + hints.extend([ ("q", "exit"), ("Tab", "cycle"), - ("1-4", "panel"), + ("1-5", "panel"), ("", "swap"), ("`", "tree"), - ("\\", view_layout_hint), - ("?", "help"), - ] + ]); + hints.push(("\\", view_layout_hint)); + if state.show_tree && state.focus == DiffModeFocus::CommitFiles && !fold_key.is_empty() { + hints.push((fold_key.as_str(), "fold/unfold")); + } + hints.push(("?", "help")); + hints }; let key_style = Style::default().fg(theme.text).add_modifier(Modifier::BOLD); @@ -458,12 +795,7 @@ fn render_status_bar( frame.render_widget(bar, area); } -fn render_dropdown( - frame: &mut Frame, - sidebar: std::rc::Rc<[Rect]>, - state: &DiffModeState, - theme: &Theme, -) { +fn render_dropdown(frame: &mut Frame, sidebar: [Rect; 4], state: &DiffModeState, theme: &Theme) { // Position dropdown below the relevant selector let anchor = if matches!( state.editing, diff --git a/src/gui/presentation/mod.rs b/src/gui/presentation/mod.rs index 1c0c69d..393adbd 100644 --- a/src/gui/presentation/mod.rs +++ b/src/gui/presentation/mod.rs @@ -11,3 +11,4 @@ pub mod remote_branches; pub mod remotes; pub mod stash; pub mod tags; +pub(crate) mod text; diff --git a/src/gui/presentation/reflog.rs b/src/gui/presentation/reflog.rs index 83ec625..350094b 100644 --- a/src/gui/presentation/reflog.rs +++ b/src/gui/presentation/reflog.rs @@ -2,10 +2,11 @@ use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::ListItem; +use super::text::plain_text; use crate::config::Theme; use crate::model::Model; -pub fn render_reflog_list<'a>(model: &Model, theme: &Theme) -> Vec> { +pub fn render_reflog_list<'a>(model: &'a Model, theme: &Theme) -> Vec> { model .reflog_commits .iter() @@ -16,7 +17,7 @@ pub fn render_reflog_list<'a>(model: &Model, theme: &Theme) -> Vec> Style::default().fg(theme.reflog_hash), ), Span::styled( - commit.name.clone(), + plain_text(&commit.name), Style::default().fg(theme.reflog_message), ), ]; diff --git a/src/gui/presentation/stash.rs b/src/gui/presentation/stash.rs index da4018f..300bfd7 100644 --- a/src/gui/presentation/stash.rs +++ b/src/gui/presentation/stash.rs @@ -2,10 +2,11 @@ use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::ListItem; +use super::text::plain_text; use crate::config::Theme; use crate::model::Model; -pub fn render_stash_list<'a>(model: &Model, theme: &Theme) -> Vec> { +pub fn render_stash_list<'a>(model: &'a Model, theme: &Theme) -> Vec> { model .stash_entries .iter() @@ -15,7 +16,10 @@ pub fn render_stash_list<'a>(model: &Model, theme: &Theme) -> Vec> format!(" {} ", entry.ref_name()), Style::default().fg(theme.stash_index), ), - Span::styled(entry.name.clone(), Style::default().fg(theme.stash_message)), + Span::styled( + plain_text(&entry.name), + Style::default().fg(theme.stash_message), + ), ]); ListItem::new(line) diff --git a/src/gui/presentation/text.rs b/src/gui/presentation/text.rs new file mode 100644 index 0000000..803c911 --- /dev/null +++ b/src/gui/presentation/text.rs @@ -0,0 +1,56 @@ +use std::borrow::Cow; + +/// Repository text is data, not terminal instructions. Strip escapes before +/// measuring/clipping it: clipping can otherwise remove an SGR reset while +/// leaving the opening escape to affect the rest of the terminal frame. +/// Keep newlines and expand tabs for multiline messages, but no other controls. +pub(crate) fn plain_text(text: &str) -> Cow<'_, str> { + if !text.chars().any(|c| c.is_control() && c != '\n') { + return Cow::Borrowed(text); + } + let input = if text.contains('\t') { + Cow::Owned(text.replace('\t', " ")) + } else { + Cow::Borrowed(text) + }; + let mut clean = strip_ansi_escapes::strip_str(input); + clean.retain(|c| !c.is_control() || c == '\n'); + Cow::Owned(clean) +} + +#[cfg(test)] +mod tests { + use super::plain_text; + + #[test] + fn clean_text_is_borrowed_including_multiline_unicode() { + for text in ["", "ordinary commit", "héllo 世界\n\nmessage body"] { + assert!( + matches!(plain_text(text), std::borrow::Cow::Borrowed(s) if std::ptr::eq(s, text)) + ); + } + } + + #[test] + fn removes_terminal_instructions_but_preserves_message_text() { + assert_eq!( + plain_text("Add Vike skill — \x1b[4mhttps://vike.dev/ai#skill\x1b[24m"), + "Add Vike skill — https://vike.dev/ai#skill" + ); + assert_eq!( + plain_text("\x1b[4munterminated style"), + "unterminated style" + ); + assert_eq!(plain_text("text\x1b[4"), "text"); + assert_eq!(plain_text("a\x1b[2Jb\x1b[Hc"), "abc"); + assert_eq!( + plain_text("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\"), + "link" + ); + assert_eq!(plain_text("\x1b]0;window title\x07text"), "text"); + assert_eq!( + plain_text("héllo 世界\n\tmessage\x07\r"), + "héllo 世界\n message" + ); + } +} diff --git a/src/gui/views.rs b/src/gui/views.rs index eef8068..d8144da 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use crate::config::{AppConfig, Theme}; +use crate::config::{AppConfig, KeybindingConfig, Theme}; use crate::model::Model; use crate::model::commit::{Commit, CommitStat}; use crate::model::file_tree::{CommitFileTreeNode, FileTreeNode}; @@ -67,6 +67,13 @@ pub fn render( ) { let area = frame.area(); let panel_count = SideWindow::ALL.len(); + let active_file_tree = match ctx_mgr.active() { + ContextId::Files => show_file_tree, + ContextId::CommitFiles | ContextId::BranchCommitFiles | ContextId::StashFiles => { + show_commit_file_tree + } + _ => false, + }; // Determine which panel index is active so it gets expanded let active_window = ctx_mgr.active_window(); @@ -197,6 +204,24 @@ pub fn render( ); } } + ContextId::Worktrees | ContextId::Submodules => { + let items = if ctx_id == ContextId::Worktrees { + render_worktree_list(model, theme) + } else { + render_submodule_list(model, theme) + }; + render_list_ctx( + frame, + fl.main_panel, + block, + items, + selected, + true, + theme, + ctx_mgr, + ctx_id, + ); + } ContextId::Branches => { let items = presentation::branches::render_branch_list( model, @@ -399,6 +424,9 @@ pub fn render( model, diff_focused, !cherry_pick_clipboard.is_empty(), + &config.user_config.keybinding, + !active_commit_filters.is_empty(), + active_file_tree, ); // Render text selection highlight overlay and tooltip (must be before popup) render_selection_overlay(frame, diff_view, fl.main_panel, theme); @@ -492,31 +520,10 @@ pub fn render( ); } ContextId::Submodules => { - if model.submodules.is_empty() { - let widget = Paragraph::new(" (no submodules)").block(block); - frame.render_widget(widget, rect); - } else { - let items: Vec = model - .submodules - .iter() - .map(|sub| { - let line = Line::from(vec![ - Span::styled( - format!(" {} ", sub.name), - Style::default().fg(theme.accent), - ), - Span::styled( - sub.path.clone(), - Style::default().fg(theme.text_dimmed), - ), - ]); - ListItem::new(line) - }) - .collect(); - render_list_ctx( - frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id, - ); - } + let items = render_submodule_list(model, theme); + render_list_ctx( + frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id, + ); } ContextId::Branches => { // If BranchCommits or BranchCommitFiles is active, render that instead @@ -1079,6 +1086,9 @@ pub fn render( model, diff_focused, !cherry_pick_clipboard.is_empty(), + &config.user_config.keybinding, + !active_commit_filters.is_empty(), + active_file_tree, ); // Render text selection highlight overlay and tooltip @@ -1216,6 +1226,22 @@ fn wrap_popup_lines(message: &str, width: usize) -> Vec { .collect() } +fn render_submodule_list<'a>(model: &Model, theme: &Theme) -> Vec> { + model + .submodules + .iter() + .map(|sub| { + ListItem::new(Line::from(vec![ + Span::styled( + format!(" {} ", sub.name), + Style::default().fg(theme.accent), + ), + Span::styled(sub.path.clone(), Style::default().fg(theme.text_dimmed)), + ])) + }) + .collect() +} + fn visible_popup_lines(wrapped: &[String], max_lines: usize) -> Vec { if wrapped.len() <= max_lines { return wrapped.to_vec(); @@ -1326,13 +1352,503 @@ pub fn checklist_item_at(popup: &PopupState, area: Rect, col: u16, row: u16) -> #[cfg(test)] mod tests { - use super::{checklist_item_at, command_log_geometry, menu_item_at, render_popup}; + use super::{ + checklist_item_at, command_log_geometry, format_key_hint, menu_item_at, render_popup, + }; use crate::config::Theme; use crate::gui::popup::{ChecklistItem, MenuItem, MessageKind, PopupState}; use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::layout::Rect; + fn render_tab( + model: &crate::model::Model, + context: super::ContextId, + mode: super::ScreenMode, + ) -> Vec { + let mut terminal = Terminal::new(TestBackend::new(160, 40)).unwrap(); + let mut contexts = super::ContextManager::new(); + contexts.set_active(context); + terminal + .draw(|frame| { + super::render( + frame, + model, + &mut contexts, + &super::LayoutState::default(), + &PopupState::None, + &crate::config::AppConfig { + debug: false, + version: String::new(), + user_config: Default::default(), + app_state: Default::default(), + config_dir: Default::default(), + state_dir: Default::default(), + state_path: Default::default(), + }, + &Theme::default(), + &mut super::DiffViewState::default(), + &mut super::presentation::commits::CommitListCache::default(), + mode, + false, + &[], + &Default::default(), + false, + None, + None, + &[], + false, + &[], + false, + &[], + &Default::default(), + "", + "", + "", + "", + super::ContextId::Commits, + 0, + None, + false, + &[], + None, + false, + false, + &Default::default(), + &Default::default(), + &mut 0, + &mut String::new(), + false, + false, + false, + ); + }) + .unwrap(); + terminal + .backend() + .buffer() + .content + .chunks(160) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect() + } + + fn sample_worktree( + path: &str, + branch: &str, + current: bool, + main: bool, + ) -> crate::model::Worktree { + crate::model::Worktree { + path: path.into(), + branch: branch.into(), + hash: String::new(), + is_current: current, + is_main: main, + } + } + + #[test] + fn footer_shows_only_enabled_folding_in_active_file_tree_lists() { + use super::{ContextId, ContextManager, DiffViewState, KeybindingConfig, Model}; + let mut kb = KeybindingConfig::default(); + kb.universal.tree_parent = "p".into(); + kb.universal.tree_child = "c".into(); + kb.universal.tree_prev_sibling.clear(); + kb.universal.tree_next_sibling = "n".into(); + let mut diff_view = DiffViewState::default(); + diff_view.load("test", "old\n", "new\n"); + for ctx in [ + ContextId::Files, + ContextId::CommitFiles, + ContextId::BranchCommitFiles, + ContextId::StashFiles, + ] { + for focused in [false, true] { + for tree in [false, true] { + for (binding, hint) in [("-", "-"), ("", "ctrl+f"), ("", "")] { + kb.universal.fold_directory = binding.into(); + let mut ctx_mgr = ContextManager::new(); + ctx_mgr.set_active(ctx); + let mut terminal = Terminal::new(TestBackend::new(240, 1)).unwrap(); + terminal + .draw(|frame| { + super::render_status_bar( + frame, + frame.area(), + &ctx_mgr, + &diff_view, + &Theme::default(), + &Model::default(), + focused, + false, + &kb, + false, + tree, + ) + }) + .unwrap(); + let text: String = (0..240) + .map(|x| terminal.backend().buffer().cell((x, 0)).unwrap().symbol()) + .collect(); + assert_eq!( + text.contains("fold/unfold"), + tree && !focused && !binding.is_empty(), + "{ctx:?} {focused} {tree} {binding}: {text}" + ); + if tree && !focused && !binding.is_empty() { + assert!(text.contains(&format!("{hint} fold/unfold")), "{text}"); + } + assert!(!text.contains("p/c nav"), "{text}"); + assert!(!text.contains("n siblings"), "{text}"); + assert!(!text.contains("details"), "{text}"); + } + } + } + } + } + + #[test] + fn commit_list_footers_omit_details_toggle() { + for ctx in [ + super::ContextId::Commits, + super::ContextId::BranchCommits, + super::ContextId::Reflog, + ] { + let mut ctx_mgr = super::ContextManager::new(); + ctx_mgr.set_active(ctx); + let mut terminal = Terminal::new(TestBackend::new(240, 1)).unwrap(); + terminal + .draw(|frame| { + super::render_status_bar( + frame, + frame.area(), + &ctx_mgr, + &super::DiffViewState::default(), + &Theme::default(), + &super::Model::default(), + false, + false, + &super::KeybindingConfig::default(), + false, + false, + ) + }) + .unwrap(); + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(!text.contains("details"), "{ctx:?}: {text}"); + assert!(!text.contains("fold/unfold"), "{ctx:?}: {text}"); + } + } + + #[test] + fn worktrees_render_names_branches_and_main_label_in_every_mode() { + let model = crate::model::Model { + worktrees: vec![ + sample_worktree("/repos/quarta", "feat/main", true, true), + sample_worktree( + "/repos/worktree-pfparser", + "experiment/parser", + false, + false, + ), + ], + ..Default::default() + }; + for mode in [ + super::ScreenMode::Normal, + super::ScreenMode::Half, + super::ScreenMode::Full, + ] { + let rows = render_tab(&model, super::ContextId::Worktrees, mode); + let main = rows.iter().find(|row| row.contains("* quarta")).unwrap(); + assert!(main.contains("* quarta")); + assert!(main.contains("feat/main (main worktree)")); + assert!(!main.contains("/repos/")); + let linked = rows + .iter() + .find(|row| row.contains("worktree-pfparser experiment/parser")) + .unwrap(); + assert!(linked.contains("worktree-pfparser experiment/parser")); + assert!(!linked.contains("(main worktree)")); + assert!(!linked.contains("/repos/")); + assert_eq!(main.find("feat/main"), linked.find("experiment/parser")); + } + } + + #[test] + fn worktree_columns_use_unicode_display_width() { + let model = crate::model::Model { + worktrees: vec![ + sample_worktree("/repos/树", "branch-one", false, false), + sample_worktree("/repos/abc", "branch-two", true, false), + ], + ..Default::default() + }; + let rows = render_tab(&model, super::ContextId::Worktrees, super::ScreenMode::Full); + assert!(rows.iter().any(|row| row.contains("树 branch-one"))); + assert!(rows.iter().any(|row| row.contains("* abc branch-two"))); + } + + #[test] + fn submodules_render_in_every_mode_and_empty_lists_stay_blank() { + let mut model = crate::model::Model::default(); + for mode in [ + super::ScreenMode::Normal, + super::ScreenMode::Half, + super::ScreenMode::Full, + ] { + let rows = render_tab(&model, super::ContextId::Submodules, mode); + assert!(!rows.join("\n").contains("(no submodules)")); + } + model.submodules.push(crate::git::submodule::Submodule { + name: "shared-lib".into(), + path: "vendor/shared-lib".into(), + url: String::new(), + }); + for mode in [ + super::ScreenMode::Normal, + super::ScreenMode::Half, + super::ScreenMode::Full, + ] { + let rows = render_tab(&model, super::ContextId::Submodules, mode); + assert!( + rows.iter() + .any(|row| row.contains("shared-lib vendor/shared-lib")) + ); + } + } + + #[test] + fn format_key_hint_formats_modifiers_and_preserves_plain() { + assert_eq!(format_key_hint(""), "ctrl+s"); + assert_eq!(format_key_hint(""), "ctrl+s"); + assert_eq!(format_key_hint(""), "ctrl+s"); + assert_eq!(format_key_hint(""), "ctrl+s"); + assert_eq!(format_key_hint("Ctrl+S"), "ctrl+s"); + assert_eq!(format_key_hint("ctrl-s"), "ctrl+s"); + assert_eq!(format_key_hint(""), "ctrl+g"); + assert_eq!(format_key_hint(""), "ctrl+l"); + assert_eq!(format_key_hint(""), "ctrl+f"); + assert_eq!(format_key_hint(""), "alt+h"); + assert_eq!(format_key_hint(""), "alt+h"); + assert_eq!(format_key_hint(""), "shift+f"); + assert_eq!(format_key_hint(""), "shift+f"); + assert_eq!(format_key_hint(""), "shift+tab"); + assert_eq!(format_key_hint(""), "space"); + assert_eq!(format_key_hint(""), "space"); + assert_eq!(format_key_hint(""), "enter"); + assert_eq!(format_key_hint(""), "esc"); + assert_eq!(format_key_hint("f"), "f"); + assert_eq!(format_key_hint("F"), "F"); + assert_eq!(format_key_hint("ctrl+g"), "ctrl+g"); + assert_eq!(format_key_hint(""), ""); + } + + #[test] + fn status_bar_shows_dual_diff_toggle_hint() { + use crate::config::KeybindingConfig; + use crate::gui::context::{ContextId, ContextManager}; + use crate::model::Model; + use crate::pager::side_by_side::DiffViewState; + + let backend = TestBackend::new(160, 1); + let mut terminal = Terminal::new(backend).expect("test terminal"); + let theme = Theme::default(); + let mut ctx_mgr = ContextManager::new(); + let diff_view = DiffViewState::new(); + let model = Model::default(); + let keybindings = KeybindingConfig::default(); + + // 1. Files context -> shows "ctrl+g head" + ctx_mgr.set_active(ContextId::Files); + terminal + .draw(|f| { + super::render_status_bar( + f, + Rect::new(0, 0, 160, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + false, + false, + &keybindings, + false, + false, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = (0..160) + .map(|x| buf.cell((x, 0)).unwrap().symbol()) + .collect(); + assert!( + content.contains("ctrl+g head"), + "Expected 'ctrl+g head' in status bar for Files, got: {}", + content + ); + + // 2. Commits context -> shows "ctrl+g files" + ctx_mgr.set_active(ContextId::Commits); + terminal + .draw(|f| { + super::render_status_bar( + f, + Rect::new(0, 0, 160, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + false, + false, + &keybindings, + false, + false, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = (0..160) + .map(|x| buf.cell((x, 0)).unwrap().symbol()) + .collect(); + assert!( + content.contains("ctrl+g files"), + "Expected 'ctrl+g files' in status bar for Commits, got: {}", + content + ); + + // 3. CommitFiles context -> shows "ctrl+g files" + ctx_mgr.set_active(ContextId::CommitFiles); + terminal + .draw(|f| { + super::render_status_bar( + f, + Rect::new(0, 0, 160, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + false, + false, + &keybindings, + false, + false, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = (0..160) + .map(|x| buf.cell((x, 0)).unwrap().symbol()) + .collect(); + assert!( + content.contains("ctrl+g files"), + "Expected 'ctrl+g files' in status bar for CommitFiles, got: {}", + content + ); + + // 4. Diff-focused mode: Files context -> shows "ctrl+g head" + ctx_mgr.set_active(ContextId::Files); + terminal + .draw(|f| { + super::render_status_bar( + f, + Rect::new(0, 0, 160, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + true, + false, + &keybindings, + false, + false, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = (0..160) + .map(|x| buf.cell((x, 0)).unwrap().symbol()) + .collect(); + assert!( + content.contains("ctrl+g head"), + "Expected 'ctrl+g head' in diff-focused status bar for Files, got: {}", + content + ); + + // 5. Diff-focused mode: Commits context -> shows "ctrl+g files" + ctx_mgr.set_active(ContextId::Commits); + terminal + .draw(|f| { + super::render_status_bar( + f, + Rect::new(0, 0, 160, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + true, + false, + &keybindings, + false, + false, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = (0..160) + .map(|x| buf.cell((x, 0)).unwrap().symbol()) + .collect(); + assert!( + content.contains("ctrl+g files"), + "Expected 'ctrl+g files' in diff-focused status bar for Commits, got: {}", + content + ); + + // 6. Custom keybinding (e.g. ) -> shows "ctrl+t head" and "ctrl+t files" + let mut custom_kb = KeybindingConfig::default(); + custom_kb.universal.toggle_working_tree_and_head = "".into(); + ctx_mgr.set_active(ContextId::Files); + terminal + .draw(|f| { + super::render_status_bar( + f, + Rect::new(0, 0, 160, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + false, + false, + &custom_kb, + false, + false, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = (0..160) + .map(|x| buf.cell((x, 0)).unwrap().symbol()) + .collect(); + assert!( + content.contains("ctrl+t head"), + "Expected 'ctrl+t head' with custom keybinding, got: {}", + content + ); + } + #[test] fn command_log_is_hidden_when_main_panel_is_absent() { assert_eq!(command_log_geometry(Rect::default(), 1), None); @@ -1458,6 +1974,105 @@ mod tests { assert_eq!(checklist_item_at(&popup, area, x, 12), None); // search row assert_eq!(checklist_item_at(&popup, area, x, 13), None); // separator } + + #[test] + fn format_key_hint_normalizes_standard_and_custom_bindings() { + use super::format_key_hint; + + assert_eq!(format_key_hint(""), "ctrl+s"); + assert_eq!(format_key_hint(""), "ctrl+l"); + assert_eq!(format_key_hint(""), "ctrl+l"); + assert_eq!(format_key_hint("ctrl+l"), "ctrl+l"); + assert_eq!(format_key_hint(""), "space"); + assert_eq!(format_key_hint(""), "esc"); + assert_eq!(format_key_hint(""), "enter"); + assert_eq!(format_key_hint("c-s"), "ctrl+s"); + assert_eq!(format_key_hint("alt-x"), "alt+x"); + assert_eq!(format_key_hint("shift-tab"), "shift+tab"); + } + + #[test] + fn render_status_bar_displays_filter_branch_and_esc_reset() { + use super::{DiffViewState, render_status_bar}; + use crate::config::KeybindingConfig; + use crate::gui::context::{ContextId, ContextManager}; + use crate::model::Model; + + let backend = TestBackend::new(120, 2); + let mut terminal = Terminal::new(backend).expect("test terminal"); + let model = Model::default(); + let keybindings = KeybindingConfig::default(); + let mut ctx_mgr = ContextManager::new(); + ctx_mgr.set_active(ContextId::Commits); + let diff_view = DiffViewState::new(); + let theme = Theme::default(); + + // With active filters + terminal + .draw(|frame| { + render_status_bar( + frame, + Rect::new(0, 0, 120, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + false, + false, + &keybindings, + true, + false, + ); + }) + .expect("should render status bar with active filters"); + + let buffer = terminal.backend().buffer(); + let content: String = buffer.content().iter().map(|c| c.symbol()).collect(); + assert!( + content.contains("reset filter"), + "expected 'reset filter' in status bar: {}", + content + ); + assert!( + content.contains("filter branch"), + "expected 'filter branch' in status bar: {}", + content + ); + + assert!(content.contains("ctrl+g files"), "{content}"); + + // Without active filters + terminal + .draw(|frame| { + render_status_bar( + frame, + Rect::new(0, 0, 120, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + false, + false, + &keybindings, + false, + false, + ); + }) + .expect("should render status bar without active filters"); + + let buffer = terminal.backend().buffer(); + let content: String = buffer.content().iter().map(|c| c.symbol()).collect(); + assert!( + !content.contains("reset filter"), + "did not expect 'reset filter' when no filters active: {}", + content + ); + assert!( + content.contains("filter branch"), + "expected 'filter branch' in status bar: {}", + content + ); + } } /// Build a window title like " 4 Commit Files (abc1234 feat: some change) ". @@ -1822,17 +2437,31 @@ fn render_status_main<'a>( } fn render_worktree_list<'a>(model: &Model, theme: &Theme) -> Vec> { - model + let names: Vec<_> = model .worktrees .iter() .map(|wt| { + std::path::Path::new(&wt.path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&wt.path) + }) + .collect(); + let name_width = names.iter().map(|name| name.width()).max().unwrap_or(0); + model + .worktrees + .iter() + .zip(names) + .map(|(wt, name)| { let marker = if wt.is_current { "* " } else { " " }; let line = Line::from(vec![ Span::styled(marker.to_string(), Style::default().fg(theme.accent)), + Span::styled(name.to_string(), Style::default().fg(theme.text)), + Span::raw(" ".repeat(name_width - name.width() + 1)), Span::styled(wt.branch.clone(), Style::default().fg(theme.ref_head)), Span::styled( - format!(" {}", wt.path), - Style::default().fg(theme.text_dimmed), + if wt.is_main { " (main worktree)" } else { "" }, + Style::default().fg(theme.text), ), ]); ListItem::new(line) @@ -2098,9 +2727,13 @@ fn get_info_content<'a>(model: &Model, ctx_mgr: &ContextManager) -> Vec Line::from(format!(" Commit: {}", commit.short_hash())), Line::from(format!( " Author: {} <{}>", - commit.author_name, commit.author_email + super::presentation::text::plain_text(&commit.author_name), + super::presentation::text::plain_text(&commit.author_email) + )), + Line::from(format!( + " Message: {}", + super::presentation::text::plain_text(&commit.name) )), - Line::from(format!(" Message: {}", commit.name)), ] } else { vec![Line::from(" No commit selected")] @@ -2194,6 +2827,48 @@ fn get_info_content<'a>(model: &Model, ctx_mgr: &ContextManager) -> Vec } } +pub(super) fn format_key_hint(key: &str) -> String { + let key = key.trim(); + let inner = if let Some(stripped) = key.strip_prefix('<').and_then(|k| k.strip_suffix('>')) { + stripped.trim() + } else { + key + }; + let inner_lower = inner.to_ascii_lowercase(); + if let Some(rest) = inner_lower + .strip_prefix("c-") + .or_else(|| inner_lower.strip_prefix("c+")) + .or_else(|| inner_lower.strip_prefix("ctrl-")) + .or_else(|| inner_lower.strip_prefix("ctrl+")) + { + format!("ctrl+{}", rest) + } else if let Some(rest) = inner_lower + .strip_prefix("a-") + .or_else(|| inner_lower.strip_prefix("a+")) + .or_else(|| inner_lower.strip_prefix("alt-")) + .or_else(|| inner_lower.strip_prefix("alt+")) + { + format!("alt+{}", rest) + } else if let Some(rest) = inner_lower + .strip_prefix("s-") + .or_else(|| inner_lower.strip_prefix("s+")) + .or_else(|| inner_lower.strip_prefix("shift-")) + .or_else(|| inner_lower.strip_prefix("shift+")) + { + format!("shift+{}", rest) + } else if inner_lower == "enter" || inner_lower == "return" { + "enter".to_string() + } else if inner_lower == "escape" || inner_lower == "esc" { + "esc".to_string() + } else if inner_lower == "space" { + "space".to_string() + } else if inner_lower == "backtab" || inner_lower == "shift-tab" || inner_lower == "shift+tab" { + "shift+tab".to_string() + } else { + key.to_string() + } +} + fn render_search_bar_or_status_bar( frame: &mut Frame, status_bar: Rect, @@ -2205,6 +2880,9 @@ fn render_search_bar_or_status_bar( model: &Model, diff_focused: bool, has_copied_commits: bool, + keybindings: &KeybindingConfig, + has_active_filters: bool, + active_file_tree: bool, ) { if let Some((query, match_count, current_match)) = search_state { let match_info = if match_count > 0 { @@ -2262,6 +2940,9 @@ fn render_search_bar_or_status_bar( model, diff_focused, has_copied_commits, + keybindings, + has_active_filters, + active_file_tree, ); } } @@ -2275,9 +2956,19 @@ fn render_status_bar( model: &Model, diff_focused: bool, has_copied_commits: bool, + keybindings: &KeybindingConfig, + has_active_filters: bool, + active_file_tree: bool, ) { let mut hints: Vec<(&str, &str)> = Vec::new(); let mut emphasized: Vec<&str> = Vec::new(); + let open_log_menu_key = format_key_hint(&keybindings.commits.open_log_menu); + let toggle_head_key = format_key_hint(&keybindings.universal.toggle_working_tree_and_head); + let fold_key = format_key_hint(&keybindings.universal.fold_directory); + + if active_file_tree && !diff_focused && !fold_key.is_empty() { + hints.push((fold_key.as_str(), "fold/unfold")); + } // When in a special state (rebasing/merging/cherry-picking), show those options prominently if model.is_rebasing { @@ -2297,6 +2988,10 @@ fn render_status_bar( let has_selection = diff_view.selected_revert_hunk.is_some(); let has_undo = !diff_view.revert_undo_stack.is_empty(); let mut idx = 0; + if !toggle_head_key.is_empty() { + hints.insert(idx, (toggle_head_key.as_str(), "head")); + idx += 1; + } if has_selection { hints.insert(idx, ("enter", "hunk menu")); emphasized.push("enter"); @@ -2308,6 +3003,15 @@ fn render_status_bar( hints.insert(idx, ("u", "undo revert")); } } else { + if (ctx_mgr.active() == ContextId::Commits + || ctx_mgr.active() == ContextId::CommitFiles + || ctx_mgr.active() == ContextId::BranchCommits + || ctx_mgr.active() == ContextId::BranchCommitFiles + || ctx_mgr.active() == ContextId::Reflog) + && !toggle_head_key.is_empty() + { + hints.push((toggle_head_key.as_str(), "files")); + } hints.push(("{/}", "prev/next hunk")); } hints.push(("[/]", "side view")); @@ -2324,10 +3028,16 @@ fn render_status_bar( }; match ctx_mgr.active() { ContextId::Files => { + if !toggle_head_key.is_empty() { + hints.push((toggle_head_key.as_str(), "head")); + } hints.extend([ ("c", "commit"), ("a", "stage all"), ("space", "toggle"), + ("`", "tree"), + ]); + hints.extend([ ("\\", view_layout_hint), ("d", "discard"), ("e", "edit"), @@ -2335,25 +3045,23 @@ fn render_status_bar( ]); } ContextId::CommitFiles | ContextId::StashFiles | ContextId::BranchCommitFiles => { - hints.extend([ - ("enter", "focus diff"), - ("\\", view_layout_hint), - ("y", "copy"), - ]); + if ctx_mgr.active() != ContextId::StashFiles && !toggle_head_key.is_empty() { + hints.push((toggle_head_key.as_str(), "files")); + } + hints.extend([("enter", "focus diff"), ("`", "tree"), ("y", "copy")]); + hints.push(("\\", view_layout_hint)); } ContextId::BranchCommits => { - hints.extend([ - ("enter", "commit files"), - ("\\", view_layout_hint), - (".", "details"), - ]); + if !toggle_head_key.is_empty() { + hints.push((toggle_head_key.as_str(), "files")); + } + hints.extend([("enter", "commit files"), ("\\", view_layout_hint)]); } ContextId::Reflog => { - hints.extend([ - ("enter", "commit files"), - ("\\", view_layout_hint), - (".", "details"), - ]); + if !toggle_head_key.is_empty() { + hints.push((toggle_head_key.as_str(), "files")); + } + hints.extend([("enter", "commit files"), ("\\", view_layout_hint)]); } ContextId::Branches => { hints.extend([ @@ -2365,6 +3073,12 @@ fn render_status_bar( ]); } ContextId::Commits => { + if !toggle_head_key.is_empty() { + hints.push((toggle_head_key.as_str(), "files")); + } + if has_active_filters { + hints.push(("esc", "reset filter")); + } if has_copied_commits { hints.push(("V", "paste (cherry-pick)")); } @@ -2374,8 +3088,10 @@ fn render_status_bar( ("g", "reset"), ("t", "revert"), ("\\", view_layout_hint), - ("ctrl+l", "filter branch"), ]); + if !open_log_menu_key.is_empty() { + hints.push((open_log_menu_key.as_str(), "filter branch")); + } } ContextId::Stash => { hints.extend([ @@ -2488,7 +3204,7 @@ pub fn render_selection_overlay( .map(|(line_idx, _, panel)| (line_idx, panel)) .unwrap_or_else(|| { ( - diff_view.scroll_offset + (top_row - pl.inner_y) as usize, + diff_view.fallback_line_idx_for_row(top_row, &pl), selection.panel, ) }); @@ -2580,7 +3296,7 @@ pub fn render_selection_overlay( let line_idx = diff_view .line_chunk_at_row(row, &pl) .map(|(line_idx, _)| line_idx) - .unwrap_or_else(|| diff_view.scroll_offset + (row - pl.inner_y) as usize); + .unwrap_or_else(|| diff_view.fallback_line_idx_for_row(row, &pl)); if let Some(diff_line) = diff_view.lines.get(line_idx) { // Skip file header separator lines. if diff_line.file_header.is_some() { diff --git a/src/main.rs b/src/main.rs index 7e9b13f..3415ca0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,10 @@ struct Cli { /// Filter commits by path (file or directory), like lazygit -f #[arg(short = 'f', long = "filter", value_name = "PATH")] filter_path: Option, + + /// Launch directly in the commits panel + #[arg(long)] + commits: bool, } #[derive(Subcommand)] @@ -115,7 +119,7 @@ fn main() { .or(cli.work_tree) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); - match app::App::new(repo_path, cli.debug, cli.filter_path) { + match app::App::new(repo_path, cli.debug, cli.filter_path, cli.commits) { Ok(app) => { if let Err(e) = app.run() { eprintln!("Error: {:#}", e); @@ -128,3 +132,17 @@ fn main() { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cli_commits_flag() { + let cli = Cli::parse_from(["lazygitrs", "--commits"]); + assert!(cli.commits); + + let cli_default = Cli::parse_from(["lazygitrs"]); + assert!(!cli_default.commits); + } +} diff --git a/src/model/file_tree.rs b/src/model/file_tree.rs index fa921cd..a547c64 100644 --- a/src/model/file_tree.rs +++ b/src/model/file_tree.rs @@ -68,6 +68,58 @@ mod tests { assert_eq!(nodes[1].path, "src/views/provider_oauth_flow.rs"); assert_eq!(nodes[1].file_index, Some(0)); } + + #[test] + fn test_tree_navigation() { + struct TestNode { + depth: usize, + } + + impl NavigableTreeNode for TestNode { + fn depth(&self) -> usize { + self.depth + } + } + + // Tree structure: + // 0: root (depth 0, dir) + // 1: child1 (depth 1, dir) + // 2: grandchild1 (depth 2, file) + // 3: grandchild2 (depth 2, file) + // 4: child2 (depth 1, file) + let nodes = vec![ + TestNode { depth: 0 }, + TestNode { depth: 1 }, + TestNode { depth: 2 }, + TestNode { depth: 2 }, + TestNode { depth: 1 }, + ]; + + for idx in [nodes.len(), usize::MAX] { + assert_eq!(find_parent_idx(&nodes, idx), None); + assert_eq!(find_first_child_idx(&nodes, idx), None); + assert_eq!(find_prev_sibling_idx(&nodes, idx), None); + assert_eq!(find_next_sibling_idx(&nodes, idx), None); + } + + // Parent navigation + assert_eq!(find_parent_idx(&nodes, 2), Some(1)); + assert_eq!(find_parent_idx(&nodes, 1), Some(0)); + assert_eq!(find_parent_idx(&nodes, 0), None); + assert_eq!(find_parent_idx(&nodes, 4), Some(0)); + + // First child navigation + assert_eq!(find_first_child_idx(&nodes, 0), Some(1)); + assert_eq!(find_first_child_idx(&nodes, 1), Some(2)); + assert_eq!(find_first_child_idx(&nodes, 2), None); // Not a directory / no child + + // Sibling navigation + assert_eq!(find_next_sibling_idx(&nodes, 2), Some(3)); // grandchild1 -> grandchild2 + assert_eq!(find_next_sibling_idx(&nodes, 3), None); // grandchild2 is last child + assert_eq!(find_next_sibling_idx(&nodes, 1), Some(4)); // child1 -> child2 (skipping grandchildren!) + assert_eq!(find_prev_sibling_idx(&nodes, 3), Some(2)); // grandchild2 -> grandchild1 + assert_eq!(find_prev_sibling_idx(&nodes, 4), Some(1)); // child2 -> child1 (skipping grandchildren!) + } } fn rename_leaf_name(name: &str) -> Option { @@ -461,3 +513,90 @@ fn compress_single_child_commit_dirs(nodes: &mut Vec) { i += 1; } } + +pub trait NavigableTreeNode { + fn depth(&self) -> usize; +} + +impl NavigableTreeNode for FileTreeNode { + fn depth(&self) -> usize { + self.depth + } +} + +impl NavigableTreeNode for CommitFileTreeNode { + fn depth(&self) -> usize { + self.depth + } +} + +pub fn find_parent_idx(nodes: &[T], current_idx: usize) -> Option { + if current_idx == 0 || nodes.is_empty() || current_idx >= nodes.len() { + return None; + } + let current_depth = nodes[current_idx].depth(); + if current_depth == 0 { + return None; + } + let target_depth = current_depth - 1; + for idx in (0..current_idx).rev() { + if nodes[idx].depth() == target_depth { + return Some(idx); + } + } + None +} + +pub fn find_first_child_idx( + nodes: &[T], + current_idx: usize, +) -> Option { + if current_idx >= nodes.len() || current_idx + 1 >= nodes.len() { + return None; + } + let current_depth = nodes[current_idx].depth(); + if nodes[current_idx + 1].depth() == current_depth + 1 { + return Some(current_idx + 1); + } + None +} + +pub fn find_prev_sibling_idx( + nodes: &[T], + current_idx: usize, +) -> Option { + if current_idx == 0 || nodes.is_empty() || current_idx >= nodes.len() { + return None; + } + let current_depth = nodes[current_idx].depth(); + for idx in (0..current_idx).rev() { + let depth = nodes[idx].depth(); + if depth < current_depth { + break; + } + if depth == current_depth { + return Some(idx); + } + } + None +} + +pub fn find_next_sibling_idx( + nodes: &[T], + current_idx: usize, +) -> Option { + if current_idx >= nodes.len() || current_idx + 1 >= nodes.len() { + return None; + } + let current_depth = nodes[current_idx].depth(); + for idx in (current_idx + 1)..nodes.len() { + let depth = nodes[idx].depth(); + if depth < current_depth { + break; + } + if depth == current_depth { + return Some(idx); + } + } + None +} diff --git a/src/pager/diff_algo.rs b/src/pager/diff_algo.rs index 6b6e862..bf61eca 100644 --- a/src/pager/diff_algo.rs +++ b/src/pager/diff_algo.rs @@ -28,6 +28,7 @@ pub fn compute_side_by_side(old: &str, new: &str, tab_width: usize) -> Vec Vec Vec V old_segments, new_segments, file_header: None, + preview_placeholder: None, section_index: 0, }); } @@ -204,6 +208,7 @@ pub fn compute_side_by_side_from_unified_diff(diff: &str, tab_width: usize) -> V old_segments: None, new_segments: None, file_header: None, + preview_placeholder: None, section_index: 0, }); old_num += 1; diff --git a/src/pager/image_preview.rs b/src/pager/image_preview.rs new file mode 100644 index 0000000..d597ca5 --- /dev/null +++ b/src/pager/image_preview.rs @@ -0,0 +1,2499 @@ +//! Before/after image preview for binary diffs. +//! +//! Draws a centered, aspect-preserving image per pane inside the diff panel. +//! The caller owns the outer border/title and hides the preview under +//! overlays via `image_preview_hidden`. Only scoped Kitty deletes are +//! supported here (`ImagePreview::cleanup`); Sixel/iTerm2 have no scoped +//! delete in ratatui-image 8.0.1 and persist once drawn. +//! +//! Private modules own capability probing, bounded source loading, and inline +//! diff eligibility. This module owns rendering and graphics lifetimes. + +use std::io::Write; +use std::path::Path; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use image::ImageFormat; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui_image::picker::{Picker, ProtocolType}; +use ratatui_image::protocol::StatefulProtocolType; +use ratatui_image::thread::{ResizeRequest, ResizeResponse, ThreadProtocol}; +use ratatui_image::{Resize, ResizeEncodeRender, StatefulImage}; + +mod capabilities; +mod inline_diff; +mod source; + +use capabilities::global_picker_ref; +pub use capabilities::initialize; +pub(crate) use inline_diff::{attach_inline_image_previews, is_image_path}; +pub use source::ImageSource; +use source::{DecodedImage, format_label_for, load_one_side}; + +use super::side_by_side::DiffSideView; +use crate::config::Theme; + +static PENDING_DELETES: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// Flush scoped deletes on the UI thread, never on decoding/resize workers. +pub fn flush_cleanup(out: &mut dyn Write) { + if let Ok(mut pending) = PENDING_DELETES.lock() { + for id in pending.drain(..) { + let _ = write!(out, "\x1b_Ga=d,d=I,i={id}\x1b\\"); + } + } + let _ = out.flush(); +} + +struct PreviewSide { + width: u32, + height: u32, + orig_width: u32, + orig_height: u32, + format: Option, + thread: ThreadProtocol, + rx: Receiver, + created: Instant, + // Stored before the protocol moves into the worker so scoped Kitty + // deletes stay possible while a resize is inflight. + kitty_id: Option, +} + +impl PreviewSide { + fn new(picker: &Picker, decoded: DecodedImage) -> Self { + let (tx_req, rx_req) = mpsc::channel::(); + let (tx_resp, rx_resp) = mpsc::channel::(); + // Move the image into the protocol: no retained clone, so memory and + // cache accounting stay single-copy. + let width = decoded.image.width(); + let height = decoded.image.height(); + let orig_width = decoded.orig_width; + let orig_height = decoded.orig_height; + let format = decoded.format; + let proto = picker.new_resize_protocol(decoded.image); + let kitty_id = match proto.protocol_type() { + StatefulProtocolType::Kitty(k) => Some(k.unique_id), + _ => None, + }; + let thread = ThreadProtocol::new(tx_req, Some(proto)); + // Worker only resize+encodes (CPU); it never touches the terminal. + // It exits when the preview is dropped (sender disconnect). + std::thread::Builder::new() + .name("lazygitrs-image-preview".into()) + .spawn(move || { + while let Ok(req) = rx_req.recv() { + match req.resize_encode() { + Ok(resp) => { + let _ = tx_resp.send(resp); + } + Err(_) => { + // Encode failed; the protocol is consumed by the + // ratatui-image API. The UI shows a fallback + // message instead of hanging (see `render_side`). + } + } + } + }) + .ok(); + Self { + width, + height, + orig_width, + orig_height, + format, + thread, + rx: rx_resp, + created: Instant::now(), + kitty_id, + } + } + + fn poll_completed(&mut self) { + while let Ok(resp) = self.rx.try_recv() { + let _ = self.thread.update_resized_protocol(resp); + } + } + + fn estimated_bytes(&self) -> usize { + // Only the thumbnail and its encoded payload are retained. + let dec = (self.width as usize) * (self.height as usize) * 4; + dec + dec * 4 / 3 + 1024 + } + + fn format_label(&self) -> &'static str { + format_label_for(self.format) + } +} + +/// Before/after image preview. `Send` so the diff loader can move it across +/// threads; rendering stays on the UI thread. Drop queues scoped deletes; +/// the UI flushes them without any worker performing terminal I/O. +pub struct ImagePreview { + old: Option, + new: Option, + font_size: (u16, u16), +} + +impl Drop for ImagePreview { + fn drop(&mut self) { + if let Ok(mut pending) = PENDING_DELETES.lock() { + pending.extend( + [&self.old, &self.new] + .into_iter() + .flatten() + .filter_map(|s| s.kitty_id), + ); + } + } +} + +const _: fn() = || { + fn assert_send() {} + assert_send::(); +}; + +impl ImagePreview { + /// Load both sides with the global picker. `None` when graphics are + /// unavailable, both sides are absent, or any present side fails. + pub fn load( + git: &crate::git::GitCommands, + old: ImageSource<'_>, + new: ImageSource<'_>, + ) -> Option { + let picker = global_picker_ref().cloned()?; + Self::load_with_picker(git, old, new, &picker) + } + + /// Picker-injecting load (tests use this, never the global). + pub(crate) fn load_with_picker( + git: &crate::git::GitCommands, + old: ImageSource<'_>, + new: ImageSource<'_>, + picker: &Picker, + ) -> Option { + if picker.protocol_type() == ProtocolType::Halfblocks { + return None; + } + let repo = git.repo_path(); + let old_dec = load_one_side(repo, old)?; + let new_dec = load_one_side(repo, new)?; + Self::from_decoded(old_dec, new_dec, picker) + } + + /// Build from already-decoded sides. `None` when both are `None` or the + /// picker is Halfblocks. + pub(crate) fn from_decoded( + old: Option, + new: Option, + picker: &Picker, + ) -> Option { + if picker.protocol_type() == ProtocolType::Halfblocks { + return None; + } + if old.is_none() && new.is_none() { + return None; + } + let font_size = picker.font_size(); + Some(Self { + old: old.map(|d| PreviewSide::new(picker, d)), + new: new.map(|d| PreviewSide::new(picker, d)), + font_size, + }) + } + + /// Approx memory held: decoded + encoded estimates for both sides. + pub fn estimated_bytes(&self) -> usize { + let mut n = 0usize; + if let Some(s) = &self.old { + n += s.estimated_bytes(); + } + if let Some(s) = &self.new { + n += s.estimated_bytes(); + } + n + std::mem::size_of::() + } + + pub fn uses_placeholders(&self) -> bool { + [&self.old, &self.new] + .into_iter() + .flatten() + .all(|side| side.kitty_id.is_some()) + } + + /// Scoped cleanup: deletes only this preview's Kitty image ids. Safe on + /// any output; non-Kitty sides are ignored. Never issues a global + /// delete-all. Call on the UI thread (never from a worker). + #[cfg(test)] + fn cleanup(&self, out: &mut dyn Write) { + for id in [&self.old, &self.new] + .into_iter() + .flatten() + .filter_map(|s| s.kitty_id) + { + let _ = write!(out, "\x1b_Ga=d,d=I,i={id}\x1b\\"); + } + let _ = out.flush(); + } +} + +/// Free-function load (forwards to [`ImagePreview::load`]). +pub fn load( + git: &crate::git::GitCommands, + old: ImageSource<'_>, + new: ImageSource<'_>, +) -> Option { + ImagePreview::load(git, old, new) +} + +/// Split `area` into `(old_pane, new_pane)` honoring `side`. +pub(crate) fn split_preview_area(area: Rect, side: DiffSideView) -> (Option, Option) { + if area.width == 0 || area.height == 0 { + return (None, None); + } + match side { + DiffSideView::OldOnly => (Some(area), None), + DiffSideView::NewOnly => (None, Some(area)), + DiffSideView::Both => { + if area.width < 2 { + return (Some(area), None); + } + let left_w = area.width / 2; + let right_w = area.width - left_w; + ( + Some(Rect::new(area.x, area.y, left_w, area.height)), + Some(Rect::new(area.x + left_w, area.y, right_w, area.height)), + ) + } + } +} + +/// Center `inner` inside `outer`. +pub(crate) fn centered_rect(outer: Rect, inner_w: u16, inner_h: u16) -> Rect { + let w = inner_w.min(outer.width); + let h = inner_h.min(outer.height); + let x = outer.x + outer.width.saturating_sub(w) / 2; + let y = outer.y + outer.height.saturating_sub(h) / 2; + Rect::new(x, y, w, h) +} + +/// Cell-space fit preserving aspect, given decoded pixels + font size. +pub(crate) fn fit_cells( + img_w: u32, + img_h: u32, + font: (u16, u16), + pane_w: u16, + pane_h: u16, +) -> (u16, u16) { + if img_w == 0 || img_h == 0 || font.0 == 0 || font.1 == 0 || pane_w == 0 || pane_h == 0 { + return (pane_w.min(1), pane_h.min(1)); + } + let max_px_w = pane_w as u64 * font.0 as u64; + let max_px_h = pane_h as u64 * font.1 as u64; + let scale = (max_px_w as f64 / img_w as f64).min(max_px_h as f64 / img_h as f64); + let scale = scale.min(1.0); + let fit_px_w = ((img_w as f64 * scale).round() as u64).max(1); + let fit_px_h = ((img_h as f64 * scale).round() as u64).max(1); + let cells_w = ((fit_px_w + font.0 as u64 - 1) / font.0 as u64).min(pane_w as u64) as u16; + let cells_h = ((fit_px_h + font.1 as u64 - 1) / font.1 as u64).min(pane_h as u64) as u16; + (cells_w.max(1), cells_h.max(1)) +} + +/// Draw the image body (outer border/title owned by the caller). +pub fn render( + frame: &mut Frame, + area: Rect, + preview: &mut ImagePreview, + side: DiffSideView, + theme: &Theme, +) { + if area.width == 0 || area.height == 0 { + return; + } + if let Some(s) = preview.old.as_mut() { + s.poll_completed(); + } + if let Some(s) = preview.new.as_mut() { + s.poll_completed(); + } + + let added = preview.old.is_none() && preview.new.is_some(); + let deleted = preview.new.is_none() && preview.old.is_some(); + // A missing version isn't a comparison: let the available image use the + // full pane, just like added text files. Explicit side controls still win. + let effective_side = match (side, added, deleted) { + (DiffSideView::Both, true, _) => DiffSideView::NewOnly, + (DiffSideView::Both, _, true) => DiffSideView::OldOnly, + _ => side, + }; + let (old_area, new_area) = split_preview_area(area, effective_side); + if let Some(a) = old_area { + render_side( + frame, + a, + preview.old.as_mut(), + true, + if deleted { "Deleted" } else { "Before" }, + preview.font_size, + theme, + ); + } + if let Some(a) = new_area { + render_side( + frame, + a, + preview.new.as_mut(), + false, + if added { "Added" } else { "After" }, + preview.font_size, + theme, + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn render_side( + frame: &mut Frame, + pane: Rect, + side: Option<&mut PreviewSide>, + is_old: bool, + title: &str, + font_size: (u16, u16), + theme: &Theme, +) { + if pane.width == 0 || pane.height == 0 { + return; + } + + let Some(state) = side else { + let status = if is_old { "Added" } else { "Deleted" }; + let header = Line::from(vec![ + Span::styled(format!("{title} "), Style::default().fg(theme.text_strong)), + Span::styled("Missing", Style::default().fg(theme.text_dimmed)), + Span::styled( + format!(" · {status}"), + Style::default().fg(theme.accent_secondary), + ), + ]); + if pane.height >= 1 { + frame.render_widget( + Paragraph::new(header), + Rect::new(pane.x, pane.y, pane.width, 1), + ); + } + if pane.height >= 3 { + let msg = Paragraph::new(Line::from(Span::styled( + "—", + Style::default().fg(theme.text_dimmed), + ))); + let centered = Rect::new( + pane.x, + pane.y + pane.height / 2, + pane.width, + 1.min(pane.height.saturating_sub(1)), + ); + frame.render_widget(msg, centered); + } + return; + }; + + let dims = format!("{}x{}", state.orig_width, state.orig_height); + let fmt = state.format_label(); + let header = Line::from(vec![ + Span::styled(format!("{title} "), Style::default().fg(theme.text_strong)), + Span::styled(dims, Style::default().fg(theme.text)), + Span::styled(format!(" · {fmt}"), Style::default().fg(theme.text_dimmed)), + ]); + let header_h: u16 = 1; + if pane.height <= header_h { + frame.render_widget(Paragraph::new(header), pane); + return; + } + frame.render_widget( + Paragraph::new(header), + Rect::new(pane.x, pane.y, pane.width, header_h), + ); + let img_area = Rect::new( + pane.x, + pane.y + header_h, + pane.width, + pane.height.saturating_sub(header_h), + ); + if img_area.width == 0 || img_area.height == 0 { + return; + } + + let fitted: Option = state + .thread + .size_for(Resize::Fit(None), img_area) + .map(|r| centered_rect(img_area, r.width, r.height)) + .or_else(|| { + let (cw, ch) = fit_cells( + state.width, + state.height, + font_size, + img_area.width, + img_area.height, + ); + Some(centered_rect(img_area, cw, ch)) + }); + let Some(dst) = fitted else { + return; + }; + if dst.width == 0 || dst.height == 0 { + return; + } + + // Pending first encode: brief Loading, then a bounded fallback message. + // Encode failures drop the protocol (ratatui-image API), so this never + // blocks; it just shows the message. + let pending = state.thread.protocol_type().is_none(); + if pending { + let waiting = state.created.elapsed(); + let label = if waiting > Duration::from_secs(2) { + "Preview unavailable" + } else { + "Loading…" + }; + let w = (label.chars().count() as u16).min(dst.width).max(1); + let msg_area = centered_rect(dst, w, 1); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + label, + Style::default().fg(theme.text_dimmed), + ))), + msg_area, + ); + state.thread.resize_encode(&Resize::Fit(None), dst); + frame.render_stateful_widget( + StatefulImage::new().resize(Resize::Fit(None)), + dst, + &mut state.thread, + ); + return; + } + + frame.render_stateful_widget( + StatefulImage::new().resize(Resize::Fit(None)), + dst, + &mut state.thread, + ); +} + +/// Fixed scroll height of an inline image section, including labels. +pub const INLINE_IMAGE_ROWS: usize = 12; +const MAX_INLINE_LOADS: usize = 2; +static INLINE_LOADS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Owned source identity; delayed loads must not borrow a diff job's buffers. +#[derive(Debug, Clone, PartialEq, Eq)] +enum InlineImageSource { + Worktree(String), + Revision { revision: String, path: String }, + Missing, +} + +impl InlineImageSource { + fn borrowed(&self) -> ImageSource<'_> { + match self { + Self::Worktree(path) => ImageSource::Worktree(path), + Self::Revision { revision, path } => ImageSource::Revision { revision, path }, + Self::Missing => ImageSource::Missing, + } + } +} + +enum InlineLoadState { + Idle, + Loading(Receiver>), + Ready(ImagePreview), + Unavailable, +} + +/// Lazy, viewport-driven image state for one file in a multi-file diff. +/// Offscreen sections keep only source metadata; at most two decodes run at once. +pub struct InlineImagePreview { + repo: std::path::PathBuf, + old: InlineImageSource, + new: InlineImageSource, + picker: Picker, + state: InlineLoadState, +} + +impl InlineImagePreview { + fn new(repo: &Path, old: InlineImageSource, new: InlineImageSource) -> Option { + let picker = global_picker_ref()?.clone(); + // Inline overlays need placeholder semantics to clear individual rows + // without erasing adjacent text. Experimental Sixel/iTerm2 stay striped. + if picker.protocol_type() != ProtocolType::Kitty { + return None; + } + Some(Self::with_picker(repo, old, new, picker)) + } + + fn with_picker( + repo: &Path, + old: InlineImageSource, + new: InlineImageSource, + picker: Picker, + ) -> Self { + Self { + repo: repo.to_path_buf(), + old, + new, + picker, + state: InlineLoadState::Idle, + } + } + + /// Release pixel/protocol memory when this section leaves the viewport. + pub fn unload(&mut self) { + if let InlineLoadState::Loading(rx) = &self.state { + match rx.try_recv() { + Err(mpsc::TryRecvError::Empty) => return, // Keep a pending job; don't duplicate it. + Ok(None) => { + self.state = InlineLoadState::Unavailable; + return; + } + _ => {} // Discard offscreen pixels, including a completed result. + } + } + if !matches!(self.state, InlineLoadState::Unavailable) { + self.state = InlineLoadState::Idle; + } + } + + /// Cached folders retain identities, not image pixels or in-flight results. + pub fn unload_for_cache(&mut self) { + if !matches!(self.state, InlineLoadState::Unavailable) { + self.state = InlineLoadState::Idle; + } + } + + #[cfg(test)] + pub(crate) fn from_preview_for_test(preview: ImagePreview) -> Self { + let mut picker = Picker::from_fontsize((8, 16)); + picker.set_protocol_type(ProtocolType::Kitty); + let mut inline = Self::with_picker( + Path::new("."), + InlineImageSource::Missing, + InlineImageSource::Missing, + picker, + ); + inline.state = InlineLoadState::Ready(preview); + inline + } + + pub fn estimated_bytes(&self) -> usize { + let pixels = match &self.state { + InlineLoadState::Ready(preview) => preview.estimated_bytes(), + _ => 0, + }; + pixels + + std::mem::size_of::() + + self.repo.as_os_str().len() + + source_bytes(&self.old) + + source_bytes(&self.new) + } + + pub fn render(&mut self, frame: &mut Frame, area: Rect, side: DiffSideView, theme: &Theme) { + if let InlineLoadState::Loading(rx) = &self.state { + match rx.try_recv() { + Ok(Some(preview)) => self.state = InlineLoadState::Ready(preview), + Ok(None) | Err(mpsc::TryRecvError::Disconnected) => { + self.state = InlineLoadState::Unavailable + } + Err(mpsc::TryRecvError::Empty) => {} + } + } + if matches!(self.state, InlineLoadState::Idle) && area.height > 1 { + self.start_load(); + } + match &mut self.state { + InlineLoadState::Ready(preview) => render(frame, area, preview, side, theme), + InlineLoadState::Unavailable => { + inline_placeholder(frame, area, "Binary cannot be previewed", theme) + } + _ => inline_placeholder(frame, area, "Loading image…", theme), + } + } + + fn start_load(&mut self) { + use std::sync::atomic::Ordering; + if INLINE_LOADS + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| { + (n < MAX_INLINE_LOADS).then_some(n + 1) + }) + .is_err() + { + return; + } + struct LoadPermit; + impl Drop for LoadPermit { + fn drop(&mut self) { + INLINE_LOADS.fetch_sub(1, Ordering::AcqRel); + } + } + let permit = LoadPermit; + let repo = self.repo.clone(); + let old = self.old.clone(); + let new = self.new.clone(); + let picker = self.picker.clone(); + let (tx, rx) = mpsc::channel(); + match std::thread::Builder::new() + .name("inline-image-load".into()) + .spawn(move || { + let _permit = permit; + let preview = (|| { + let old = load_one_side(&repo, old.borrowed())?; + let new = load_one_side(&repo, new.borrowed())?; + ImagePreview::from_decoded(old, new, &picker) + })(); + let _ = tx.send(preview); + }) { + Ok(_) => self.state = InlineLoadState::Loading(rx), + Err(_) => self.state = InlineLoadState::Unavailable, + } + } +} + +fn source_bytes(source: &InlineImageSource) -> usize { + match source { + InlineImageSource::Worktree(path) => path.len(), + InlineImageSource::Revision { revision, path } => revision.len() + path.len(), + InlineImageSource::Missing => 0, + } +} + +fn inline_placeholder(frame: &mut Frame, area: Rect, message: &str, theme: &Theme) { + let style = Style::default().fg(theme.text_dimmed); + frame.render_widget( + Paragraph::new("╱".repeat(area.width as usize)).style(style), + area, + ); + if area.height > 0 { + let label = Rect::new(area.x, area.y + area.height / 2, area.width, 1); + frame.render_widget( + Paragraph::new(message) + .alignment(ratatui::layout::Alignment::Center) + .style(style), + label, + ); + } +} + +#[cfg(test)] +mod tests { + use super::source::*; + use super::*; + use image::DynamicImage; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use std::path::PathBuf; + + fn halfblocks_picker() -> Picker { + let mut p = Picker::from_fontsize((8, 16)); + p.set_protocol_type(ProtocolType::Halfblocks); + p + } + + fn graphics_picker() -> Picker { + let mut p = Picker::from_fontsize((8, 16)); + p.set_protocol_type(ProtocolType::Kitty); + p + } + + fn png_bytes(w: u32, h: u32) -> Vec { + let img = DynamicImage::ImageRgb8(image::ImageBuffer::from_pixel( + w, + h, + image::Rgb([200, 30, 30]), + )); + let mut buf = Vec::new(); + let mut cur = std::io::Cursor::new(&mut buf); + img.write_to(&mut cur, ImageFormat::Png).unwrap(); + buf + } + + fn jpeg_bytes(w: u32, h: u32) -> Vec { + let img = DynamicImage::ImageRgb8(image::ImageBuffer::from_pixel( + w, + h, + image::Rgb([30, 200, 30]), + )); + let mut buf = Vec::new(); + let mut cur = std::io::Cursor::new(&mut buf); + img.write_to(&mut cur, ImageFormat::Jpeg).unwrap(); + buf + } + + fn gif_bytes() -> Vec { + vec![ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xff, 0x21, 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, + 0x3b, + ] + } + + #[test] + fn decode_png_detects_type_and_dims() { + let b = png_bytes(4, 3); + let d = decode_bytes(&b).expect("png decodes"); + assert_eq!((d.width(), d.height()), (4, 3)); + assert_eq!(d.format, Some(ImageFormat::Png)); + assert_eq!(d.format_label(), "PNG"); + } + + #[test] + fn decode_jpeg_detects_type() { + let b = jpeg_bytes(6, 5); + let d = decode_bytes(&b).expect("jpeg decodes"); + assert_eq!(d.format, Some(ImageFormat::Jpeg)); + assert_eq!(d.format_label(), "JPEG"); + } + + #[test] + fn decode_gif_detects_type() { + let d = decode_bytes(&gif_bytes()).expect("gif decodes"); + assert_eq!(d.format, Some(ImageFormat::Gif)); + assert_eq!(d.format_label(), "GIF"); + } + + #[test] + fn decode_garbage_returns_none() { + assert!(decode_bytes(b"not an image at all").is_none()); + assert!(decode_bytes(&[]).is_none()); + assert!(decode_bytes(&[0u8; 16]).is_none()); + } + + #[test] + fn decode_rejects_oversize_compressed() { + let big = vec![0u8; MAX_COMPRESSED_BYTES + 1]; + assert!(decode_bytes(&big).is_none()); + } + + #[test] + fn downsize_preserves_aspect_within_box() { + let b = png_bytes(3000, 2000); + let d = decode_bytes(&b).expect("large png decodes"); + assert_eq!((d.orig_width, d.orig_height), (3000, 2000)); + assert!(d.width() <= PREVIEW_MAX_WIDTH && d.height() <= PREVIEW_MAX_HEIGHT); + let r = d.width() as f64 / d.height() as f64; + assert!((r - 1.5).abs() < 0.05, "aspect kept, got {r}"); + } + + #[test] + fn small_images_are_not_upscaled() { + let b = png_bytes(8, 6); + let d = decode_bytes(&b).unwrap(); + assert_eq!((d.width(), d.height()), (8, 6)); + } + + #[test] + fn format_labels_cover_contract() { + assert_eq!(format_label_for(Some(ImageFormat::Png)), "PNG"); + assert_eq!(format_label_for(Some(ImageFormat::Jpeg)), "JPEG"); + assert_eq!(format_label_for(Some(ImageFormat::Gif)), "GIF"); + assert_eq!(format_label_for(Some(ImageFormat::WebP)), "WEBP"); + assert_eq!(format_label_for(Some(ImageFormat::Bmp)), "BMP"); + assert_eq!(format_label_for(Some(ImageFormat::Ico)), "ICO"); + assert_eq!(format_label_for(Some(ImageFormat::Tiff)), "TIFF"); + assert_eq!(format_label_for(None), "IMG"); + } + + #[test] + fn split_layout_honors_side_view() { + let area = Rect::new(0, 0, 100, 20); + let (o, n) = split_preview_area(area, DiffSideView::Both); + let (o, n) = (o.unwrap(), n.unwrap()); + assert_eq!(o.width + n.width, 100); + assert_eq!((o.x, o.y, o.height), (0, 0, 20)); + assert_eq!((n.x, n.y, n.height), (50, 0, 20)); + + let (o, n) = split_preview_area(area, DiffSideView::OldOnly); + assert!(o.is_some() && n.is_none()); + let (o, n) = split_preview_area(area, DiffSideView::NewOnly); + assert!(o.is_none() && n.is_some()); + + let empty = Rect::new(0, 0, 0, 0); + assert_eq!(split_preview_area(empty, DiffSideView::Both), (None, None)); + } + + #[test] + fn centered_rect_is_centered_and_clamped() { + let outer = Rect::new(10, 5, 20, 10); + let c = centered_rect(outer, 8, 4); + assert_eq!((c.x, c.y, c.width, c.height), (16, 8, 8, 4)); + let big = centered_rect(outer, 100, 100); + assert_eq!((big.x, big.y, big.width, big.height), (10, 5, 20, 10)); + } + + #[test] + fn fit_cells_preserves_aspect_and_clamps() { + let (w, h) = fit_cells(160, 120, (8, 16), 20, 10); + assert!(w <= 20 && h <= 10 && w >= 1 && h >= 1); + let (w2, h2) = fit_cells(8, 8, (8, 16), 40, 20); + assert_eq!((w2, h2), (1, 1)); + assert_eq!(fit_cells(0, 0, (8, 16), 10, 10), (1, 1)); + } + + #[test] + fn from_decoded_needs_picker_and_a_side() { + let proto = graphics_picker(); + let d = decode_bytes(&png_bytes(4, 4)).unwrap(); + assert!(ImagePreview::from_decoded(None, None, &proto).is_none()); + assert!(ImagePreview::from_decoded(Some(d.clone()), None, &proto).is_some()); + assert!(ImagePreview::from_decoded(None, Some(d), &proto).is_some()); + let half = halfblocks_picker(); + let d2 = decode_bytes(&png_bytes(4, 4)).unwrap(); + assert!(ImagePreview::from_decoded(Some(d2), None, &half).is_none()); + } + + #[test] + fn estimated_bytes_counts_decoded_plus_encoded() { + let proto = graphics_picker(); + let d = decode_bytes(&png_bytes(8, 8)).unwrap(); + let p = ImagePreview::from_decoded(Some(d), None, &proto).unwrap(); + let dec = 8usize * 8 * 4; + let est = p.estimated_bytes(); + assert!(est >= dec + dec * 4 / 3, "est {est} covers decoded+encoded"); + assert!(est < dec * 10 + 8192, "est {est} stays plausible"); + let d2 = decode_bytes(&png_bytes(8, 8)).unwrap(); + let d3 = decode_bytes(&jpeg_bytes(8, 8)).unwrap(); + let p2 = ImagePreview::from_decoded(Some(d2), Some(d3), &proto).unwrap(); + assert!(p2.estimated_bytes() > est); + } + + #[test] + fn kitty_id_stored_for_inflight_cleanup() { + let picker = graphics_picker(); + let d = decode_bytes(&png_bytes(8, 8)).unwrap(); + let p = ImagePreview::from_decoded(Some(d), None, &picker).unwrap(); + // Stored id is available even before any encode completes. + let id = p.old.as_ref().unwrap().kitty_id.expect("kitty id stored"); + let mut buf = Vec::new(); + p.cleanup(&mut buf); + let out = String::from_utf8(buf).expect("cleanup writes ascii"); + assert!(out.contains("\x1b_Ga=d,d=I"), "scoped delete, got {out:?}"); + assert!(out.contains(&format!("i={id}")), "own id only, got {out:?}"); + } + + #[test] + fn image_source_variants_exist() { + let a = ImageSource::Worktree("a.png"); + let b = ImageSource::Revision { + revision: "HEAD", + path: "a.png", + }; + let c = ImageSource::Missing; + assert_ne!(a, b); + assert_ne!(b, c); + } + + #[test] + fn render_halfblocks_body_does_not_panic() { + let picker = halfblocks_picker(); + let img = decode_bytes(&png_bytes(16, 16)).unwrap().image; + let mut proto = picker.new_resize_protocol(img); + let backend = TestBackend::new(40, 12); + let mut term = Terminal::new(backend).expect("test terminal"); + term.draw(|f| { + let area = f.area(); + f.render_stateful_widget( + StatefulImage::new().resize(Resize::Fit(None)), + area, + &mut proto, + ); + }) + .unwrap(); + } + + #[test] + fn render_preview_body_splits_and_labels() { + let picker = graphics_picker(); + let old = decode_bytes(&png_bytes(16, 16)).unwrap(); + let new = decode_bytes(&jpeg_bytes(16, 16)).unwrap(); + let mut preview = ImagePreview::from_decoded(Some(old), Some(new), &picker).unwrap(); + let theme = Theme::default(); + let backend = TestBackend::new(80, 24); + let mut term = Terminal::new(backend).expect("test terminal"); + term.draw(|f| { + render(f, f.area(), &mut preview, DiffSideView::Both, &theme); + }) + .unwrap(); + let buf = term.backend().buffer().clone(); + let text: String = buf + .content() + .iter() + .map(|c| c.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(text.contains("Before"), "has Before label"); + assert!(text.contains("After"), "has After label"); + assert!(text.contains("PNG") || text.contains("JPEG")); + + term.draw(|f| { + render(f, f.area(), &mut preview, DiffSideView::OldOnly, &theme); + }) + .unwrap(); + let buf = term.backend().buffer().clone(); + let text: String = buf + .content() + .iter() + .map(|c| c.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(text.contains("Before")); + assert!(!text.contains("After")); + } + + #[test] + fn added_and_deleted_images_use_a_single_full_width_pane() { + for added in [true, false] { + let picker = graphics_picker(); + let image = decode_bytes(&png_bytes(8, 8)).unwrap(); + let (old, new) = if added { + (None, Some(image)) + } else { + (Some(image), None) + }; + let mut preview = ImagePreview::from_decoded(old, new, &picker).unwrap(); + let theme = Theme::default(); + let mut term = Terminal::new(TestBackend::new(80, 12)).unwrap(); + term.draw(|f| render(f, f.area(), &mut preview, DiffSideView::Both, &theme)) + .unwrap(); + let text = buffer_text(term.backend().buffer()); + assert!(text.starts_with(if added { "Added " } else { "Deleted " })); + assert!(!text.contains("Missing")); + assert!(!text.contains("Before")); + assert!(!text.contains("After")); + + // An explicit request for the absent side is still respected. + let absent = if added { + DiffSideView::OldOnly + } else { + DiffSideView::NewOnly + }; + term.draw(|f| render(f, f.area(), &mut preview, absent, &theme)) + .unwrap(); + assert!(buffer_text(term.backend().buffer()).contains("Missing")); + } + } + + #[test] + fn tiny_area_never_panics() { + let picker = graphics_picker(); + let d = decode_bytes(&png_bytes(8, 8)).unwrap(); + let mut preview = ImagePreview::from_decoded(Some(d.clone()), Some(d), &picker).unwrap(); + let theme = Theme::default(); + for (w, h) in [(0, 0), (1, 1), (2, 1), (1, 2), (3, 3)] { + let backend = TestBackend::new(w, h); + let mut term = Terminal::new(backend).expect("test terminal"); + term.draw(|f| { + render(f, f.area(), &mut preview, DiffSideView::Both, &theme); + }) + .unwrap(); + } + } + + #[test] + fn preview_is_send() { + fn assert_send() {} + assert_send::(); + } + + // ------------------------------------------------------------------ + // Regression helpers: isolated temp git fixtures, no global mutation. + // ------------------------------------------------------------------ + + fn iterm2_picker() -> Picker { + let mut p = Picker::from_fontsize((8, 16)); + p.set_protocol_type(ProtocolType::Iterm2); + p + } + + fn colored_png(rgb: [u8; 3], w: u32, h: u32) -> Vec { + let img = DynamicImage::ImageRgb8(image::ImageBuffer::from_pixel(w, h, image::Rgb(rgb))); + let mut buf = Vec::new(); + let mut cur = std::io::Cursor::new(&mut buf); + img.write_to(&mut cur, ImageFormat::Png).unwrap(); + buf + } + + fn buffer_text(buf: &ratatui::buffer::Buffer) -> String { + buf.content() + .iter() + .map(|c| c.symbol().chars().next().unwrap_or(' ')) + .collect() + } + + fn git_cmd(repo: &Path, args: &[&str]) { + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(repo).args(args); + // Isolated, non-interactive reads/writes; per-command env only, + // never mutates the test process environment. + cmd.env("GIT_OPTIONAL_LOCKS", "0"); + cmd.env("GIT_TERMINAL_PROMPT", "0"); + cmd.env("SSH_ASKPASS_REQUIRE", "never"); + if std::env::var_os("GIT_SSH_COMMAND").is_none() { + cmd.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes"); + } + cmd.env_remove("GIT_DIR"); + cmd.env_remove("GIT_WORK_TREE"); + cmd.env_remove("GIT_INDEX_FILE"); + cmd.env_remove("GIT_PREFIX"); + let status = cmd.status().expect("run git"); + assert!( + status.success(), + "git {args:?} failed in {}", + repo.display() + ); + } + + fn git_output(repo: &Path, args: &[&str]) -> String { + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(repo).args(args); + cmd.env("GIT_OPTIONAL_LOCKS", "0"); + cmd.env("GIT_TERMINAL_PROMPT", "0"); + cmd.env("SSH_ASKPASS_REQUIRE", "never"); + if std::env::var_os("GIT_SSH_COMMAND").is_none() { + cmd.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes"); + } + cmd.env_remove("GIT_DIR"); + cmd.env_remove("GIT_WORK_TREE"); + cmd.env_remove("GIT_INDEX_FILE"); + cmd.env_remove("GIT_PREFIX"); + let out = cmd.output().expect("git output"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + struct TempRepo { + path: PathBuf, + } + + impl TempRepo { + fn new(prefix: &str) -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "lazygitrs-img-{prefix}-{}-{nanos}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create temp dir"); + git_cmd(&path, &["init", "-q"]); + Self { path } + } + + fn git(&self, args: &[&str]) { + git_cmd(&self.path, args); + } + + fn commit(&self, msg: &str) { + self.git(&[ + "-c", + "user.email=test@example.com", + "-c", + "user.name=Test", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + msg, + ]); + } + + fn write(&self, rel: &str, bytes: &[u8]) { + let full = self.path.join(rel); + if let Some(parent) = full.parent() { + std::fs::create_dir_all(parent).expect("mkdir parent"); + } + std::fs::write(&full, bytes).expect("write fixture"); + } + + fn git_commands(&self) -> crate::git::GitCommands { + crate::git::GitCommands::new(&self.path).expect("GitCommands") + } + + fn repo_path(&self) -> PathBuf { + self.git_commands().repo_path().to_path_buf() + } + } + + impl Drop for TempRepo { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + #[test] + fn head_index_worktree_preserve_raw_bytes() { + let repo = TempRepo::new("raw-bytes"); + let red = colored_png([220, 30, 30], 16, 16); + let green = colored_png([30, 220, 30], 16, 16); + let blue = colored_png([30, 30, 220], 16, 16); + assert_ne!(red, green); + assert_ne!(green, blue); + + // HEAD = red. + repo.write("img.png", &red); + repo.git(&["add", "img.png"]); + repo.commit("head red"); + + // Index = green (staged), worktree = blue (unstaged). + repo.write("img.png", &green); + repo.git(&["add", "img.png"]); + repo.write("img.png", &blue); + + let repo_path = repo.repo_path(); + // Raw bytes preserved per location via private helpers. + match read_revision_blob(&repo_path, "HEAD", "img.png") { + RevisionRead::Present(b) => assert_eq!(b, red, "HEAD bytes preserved"), + other => panic!( + "HEAD should be present, got {}", + matches!(other, RevisionRead::Absent) as u8 + ), + } + match read_revision_blob(&repo_path, "", "img.png") { + RevisionRead::Present(b) => assert_eq!(b, green, "index (:path) bytes preserved"), + _ => panic!("empty revision should read index"), + } + match read_worktree_bytes(&repo_path, "img.png") { + Ok(Some(b)) => assert_eq!(b, blue, "worktree bytes preserved"), + other => panic!("worktree should be present, got {other:?}"), + } + + // Decoded sides keep distinct colors (no downscale at 16x16). + let old = load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + ) + .expect("HEAD loads") + .expect("HEAD decodes"); + let staged = load_one_side( + &repo_path, + ImageSource::Revision { + revision: "", + path: "img.png", + }, + ) + .expect("index loads") + .expect("index decodes"); + let work = load_one_side(&repo_path, ImageSource::Worktree("img.png")) + .expect("worktree loads") + .expect("worktree decodes"); + assert_eq!((old.width(), old.height()), (16, 16)); + assert_eq!( + old.image.to_rgb8().get_pixel(0, 0).0, + [220, 30, 30], + "HEAD is red" + ); + assert_eq!( + staged.image.to_rgb8().get_pixel(0, 0).0, + [30, 220, 30], + "index is green" + ); + assert_eq!( + work.image.to_rgb8().get_pixel(0, 0).0, + [30, 30, 220], + "worktree is blue" + ); + + // Full preview loads both sides with an injected picker. + let git = repo.git_commands(); + let picker = graphics_picker(); + let preview = ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + ImageSource::Worktree("img.png"), + &picker, + ) + .expect("preview loads HEAD vs worktree"); + assert!(preview.old.is_some() && preview.new.is_some()); + assert!(preview.estimated_bytes() > 0); + } + + #[test] + fn empty_revision_means_index_not_head() { + let repo = TempRepo::new("empty-rev"); + let red = colored_png([200, 30, 30], 12, 12); + let green = colored_png([30, 200, 30], 12, 12); + repo.write("a.png", &red); + repo.git(&["add", "a.png"]); + repo.commit("init"); + + // No staged change: index == HEAD. + let repo_path = repo.repo_path(); + let head = match read_revision_blob(&repo_path, "HEAD", "a.png") { + RevisionRead::Present(b) => b, + _ => panic!("HEAD present"), + }; + let index = match read_revision_blob(&repo_path, "", "a.png") { + RevisionRead::Present(b) => b, + _ => panic!("index present"), + }; + assert_eq!(head, index, "clean index matches HEAD"); + assert_eq!(head, red); + + // Stage a different image: empty revision follows the index. + repo.write("a.png", &green); + repo.git(&["add", "a.png"]); + let head2 = match read_revision_blob(&repo_path, "HEAD", "a.png") { + RevisionRead::Present(b) => b, + _ => panic!("HEAD present"), + }; + let index2 = match read_revision_blob(&repo_path, "", "a.png") { + RevisionRead::Present(b) => b, + _ => panic!("index present"), + }; + assert_eq!(head2, red, "HEAD unchanged"); + assert_eq!(index2, green, "empty revision reads staged bytes"); + + let via_helper = load_one_side( + &repo_path, + ImageSource::Revision { + revision: "", + path: "a.png", + }, + ) + .expect("index loads") + .expect("index decodes"); + assert_eq!(via_helper.image.to_rgb8().get_pixel(0, 0).0, [30, 200, 30]); + + // Injected Iterm2 picker behaves like Kitty here. + let git = repo.git_commands(); + for picker in [graphics_picker(), iterm2_picker()] { + let preview = ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "", + path: "a.png", + }, + ImageSource::Revision { + revision: "HEAD", + path: "a.png", + }, + &picker, + ) + .expect("index vs HEAD preview loads"); + assert!(preview.old.is_some() && preview.new.is_some()); + let _ = picker.protocol_type(); + } + } + + #[test] + fn root_commit_missing_parent_is_absent() { + let repo = TempRepo::new("root-parent"); + let red = colored_png([200, 30, 30], 10, 10); + repo.write("img.png", &red); + repo.git(&["add", "img.png"]); + repo.commit("root"); + + let repo_path = repo.repo_path(); + let hash = git_output(&repo.path, &["rev-parse", "HEAD"]); + assert!(!hash.is_empty()); + let parent_spec = format!("{hash}^1"); + + // Missing parent blob is legitimately absent, not a failure. + match read_revision_blob(&repo_path, &parent_spec, "img.png") { + RevisionRead::Absent => {} + RevisionRead::Present(_) => panic!("parent should be absent"), + RevisionRead::Failed => panic!("parent should be absent, not failed"), + } + assert!(matches!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: &parent_spec, + path: "img.png", + } + ), + Some(None) + )); + // HEAD^1 shorthand behaves the same on a root commit. + assert!(matches!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD^1", + path: "img.png", + } + ), + Some(None) + )); + + // Preview treats absent old as added (new present). + let git = repo.git_commands(); + let picker = graphics_picker(); + let preview = ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: &parent_spec, + path: "img.png", + }, + ImageSource::Revision { + revision: &hash, + path: "img.png", + }, + &picker, + ) + .expect("root preview loads"); + assert!(preview.old.is_none(), "old absent on root"); + assert!(preview.new.is_some(), "new present on root"); + } + + #[test] + fn added_and_deleted_sides_via_missing() { + // Pure helper contract without git. + let picker = graphics_picker(); + let decoded = decode_bytes(&colored_png([10, 200, 10], 8, 8)).unwrap(); + assert!(ImagePreview::from_decoded(None, Some(decoded.clone()), &picker).is_some()); + assert!(ImagePreview::from_decoded(Some(decoded), None, &picker).is_some()); + assert!(ImagePreview::from_decoded(None, None, &picker).is_none()); + + // Missing via private helper is Some(None), never a failure. + let repo = TempRepo::new("added-deleted"); + let bytes = colored_png([200, 30, 30], 8, 8); + repo.write("img.png", &bytes); + repo.git(&["add", "img.png"]); + repo.commit("init"); + let repo_path = repo.repo_path(); + assert!(matches!( + load_one_side(&repo_path, ImageSource::Missing), + Some(None) + )); + // Worktree file removed => legitimately missing. + std::fs::remove_file(repo.path.join("img.png")).expect("remove worktree file"); + assert!(matches!( + read_worktree_bytes(&repo_path, "img.png"), + Ok(None) + )); + assert!(matches!( + load_one_side(&repo_path, ImageSource::Worktree("img.png")), + Some(None) + )); + + // Added (old missing) and deleted (new missing) previews load. + let git = repo.git_commands(); + let added = ImagePreview::load_with_picker( + &git, + ImageSource::Missing, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + &picker, + ) + .expect("added loads"); + assert!(added.old.is_none() && added.new.is_some()); + // Worktree file is gone, so deleted shows old only. + let deleted = ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + ImageSource::Missing, + &picker, + ) + .expect("deleted loads"); + assert!(deleted.old.is_some() && deleted.new.is_none()); + // Both missing => no preview at all. + assert!( + ImagePreview::load_with_picker( + &git, + ImageSource::Missing, + ImageSource::Missing, + &picker + ) + .is_none() + ); + } + + #[test] + fn rename_uses_before_path_after_path() { + let repo = TempRepo::new("rename-paths"); + let bytes = colored_png([30, 30, 220], 14, 14); + repo.write("before.png", &bytes); + repo.git(&["add", "before.png"]); + repo.commit("add before"); + repo.git(&["mv", "before.png", "after.png"]); + repo.commit("rename"); + + let repo_path = repo.repo_path(); + // Old path only exists at the parent, new path only at HEAD. + let old_bytes = match read_revision_blob(&repo_path, "HEAD^", "before.png") { + RevisionRead::Present(b) => b, + _ => panic!("before.png at HEAD^"), + }; + let new_bytes = match read_revision_blob(&repo_path, "HEAD", "after.png") { + RevisionRead::Present(b) => b, + _ => panic!("after.png at HEAD"), + }; + assert_eq!(old_bytes, bytes); + assert_eq!(new_bytes, bytes); + assert!(matches!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD", + path: "before.png", + } + ), + Some(None) + )); + assert!(matches!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD^", + path: "after.png", + } + ), + Some(None) + )); + + let git = repo.git_commands(); + let picker = graphics_picker(); + let preview = ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "HEAD^", + path: "before.png", + }, + ImageSource::Revision { + revision: "HEAD", + path: "after.png", + }, + &picker, + ) + .expect("rename preview loads before/after paths"); + assert!(preview.old.is_some() && preview.new.is_some()); + } + + #[test] + fn corrupt_side_makes_entire_preview_none() { + let repo = TempRepo::new("corrupt-side"); + let valid = colored_png([200, 30, 30], 12, 12); + repo.write("img.png", &valid); + repo.git(&["add", "img.png"]); + repo.commit("valid"); + + // Corrupt worktree: entire load fails, not a partial preview. + repo.write("img.png", b"definitely not an image"); + let repo_path = repo.repo_path(); + assert!(decode_bytes(b"definitely not an image").is_none()); + assert!(load_one_side(&repo_path, ImageSource::Worktree("img.png")).is_none()); + + let git = repo.git_commands(); + let picker = graphics_picker(); + assert!( + ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + ImageSource::Worktree("img.png"), + &picker, + ) + .is_none(), + "corrupt new side must fail the whole preview" + ); + assert!( + ImagePreview::load_with_picker( + &git, + ImageSource::Worktree("img.png"), + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + &picker, + ) + .is_none(), + "corrupt old side must fail the whole preview" + ); + + // Corrupt revision side behaves the same. + repo.write("img.png", b"still not an image"); + repo.git(&["add", "img.png"]); + repo.commit("corrupt revision"); + assert!( + ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "HEAD^", + path: "img.png", + }, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + &picker, + ) + .is_none(), + "corrupt revision must fail the whole preview" + ); + let _ = picker.protocol_type(); + } + + #[test] + fn decode_rejects_over_dimension_limit() { + // Width far over MAX_IMAGE_DIMENSION with tiny height keeps the + // allocation small while still exercising the dimension guard. + let wide = colored_png([10, 10, 10], MAX_IMAGE_DIMENSION + 1000, 16); + assert!( + decode_bytes(&wide).is_none(), + "width over limit must not decode" + ); + let tall = colored_png([10, 10, 10], 16, MAX_IMAGE_DIMENSION + 1000); + assert!( + decode_bytes(&tall).is_none(), + "height over limit must not decode" + ); + + // Same failure surfaces through the worktree helper without a repo. + let repo = TempRepo::new("over-dim"); + repo.write("wide.png", &wide); + let repo_path = repo.repo_path(); + assert!(load_one_side(&repo_path, ImageSource::Worktree("wide.png")).is_none()); + let git = repo.git_commands(); + let picker = graphics_picker(); + assert!( + ImagePreview::load_with_picker( + &git, + ImageSource::Worktree("wide.png"), + ImageSource::Missing, + &picker, + ) + .is_none() + ); + } + + #[test] + fn decode_rejects_over_alloc_limit() { + // 5000x4500 RGB is ~67MiB decoded (>64MiB cap) while staying inside + // the 16384 dimension cap. Uniform color keeps the PNG itself small. + let big = colored_png([40, 40, 200], 5000, 4500); + assert!( + decode_bytes(&big).is_none(), + "decoded alloc over limit must not decode" + ); + } + + #[test] + fn worktree_oversize_is_bounded_without_writing_20mib() { + let repo = TempRepo::new("oversize-worktree"); + let small = colored_png([200, 30, 30], 8, 8); + repo.write("big.png", &small); + // Sparse extension: no 20MiB write, just a size bump past the cap. + let full = repo.path.join("big.png"); + let file = std::fs::OpenOptions::new() + .write(true) + .open(&full) + .expect("open"); + file.set_len((MAX_COMPRESSED_BYTES as u64) + 1) + .expect("sparse oversize"); + drop(file); + assert_eq!( + std::fs::metadata(&full).expect("meta").len(), + (MAX_COMPRESSED_BYTES as u64) + 1 + ); + + let repo_path = repo.repo_path(); + assert!(matches!( + read_worktree_bytes(&repo_path, "big.png"), + Err(()) + )); + assert!(load_one_side(&repo_path, ImageSource::Worktree("big.png")).is_none()); + let git = repo.git_commands(); + let picker = graphics_picker(); + assert!( + ImagePreview::load_with_picker( + &git, + ImageSource::Worktree("big.png"), + ImageSource::Missing, + &picker, + ) + .is_none() + ); + } + + #[test] + fn headers_show_original_dimensions_not_downscaled() { + // 3000x2000 downsizes to ~1600x1066 for the protocol, but headers + // and cache accounting must preserve the original size. + let bytes = colored_png([200, 30, 30], 3000, 2000); + let decoded = decode_bytes(&bytes).expect("large png decodes"); + assert_eq!((decoded.orig_width, decoded.orig_height), (3000, 2000)); + assert!( + decoded.width() <= PREVIEW_MAX_WIDTH && decoded.height() <= PREVIEW_MAX_HEIGHT, + "protocol image is downsized" + ); + assert!( + decoded.width() != decoded.orig_width, + "fixture must actually downscale" + ); + + let picker = graphics_picker(); + let preview = ImagePreview::from_decoded(Some(decoded), None, &picker).unwrap(); + let side = preview.old.as_ref().unwrap(); + assert_eq!((side.orig_width, side.orig_height), (3000, 2000)); + // Only the thumbnail is retained; original dimensions are metadata. + let dec_orig = side.width as usize * side.height as usize * 4; + assert!( + preview.estimated_bytes() >= dec_orig + dec_orig * 4 / 3, + "estimate covers original, got {}", + preview.estimated_bytes() + ); + + let mut live = preview; + let theme = Theme::default(); + let backend = TestBackend::new(80, 24); + let mut term = Terminal::new(backend).expect("test terminal"); + term.draw(|f| { + render(f, f.area(), &mut live, DiffSideView::Both, &theme); + }) + .unwrap(); + let text = buffer_text(term.backend().buffer()); + assert!( + text.contains("3000x2000"), + "header shows original dims, got {text:?}" + ); + assert!( + !text.contains(&format!( + "{}x{}", + live.old.as_ref().unwrap().width, + live.old.as_ref().unwrap().height + )) || text.contains("3000x2000"), + "must not show only downsized dims" + ); + } + + #[test] + fn async_render_eventually_encodes_with_test_backend() { + let picker = graphics_picker(); + let decoded = decode_bytes(&colored_png([30, 200, 30], 32, 32)).unwrap(); + let mut preview = ImagePreview::from_decoded(Some(decoded), None, &picker).unwrap(); + let theme = Theme::default(); + let backend = TestBackend::new(80, 24); + let mut term = Terminal::new(backend).expect("test terminal"); + + // First frame kicks the background resize+encode. + term.draw(|f| { + render(f, f.area(), &mut preview, DiffSideView::Both, &theme); + }) + .unwrap(); + let first = buffer_text(term.backend().buffer()); + assert!(first.contains("Deleted"), "header visible while loading"); + + // Poll via repeated renders until the worker returns the protocol. + let mut encoded = false; + for _ in 0..250 { + std::thread::sleep(std::time::Duration::from_millis(20)); + term.draw(|f| { + render(f, f.area(), &mut preview, DiffSideView::Both, &theme); + }) + .unwrap(); + if preview + .old + .as_ref() + .expect("old side") + .thread + .protocol_type() + .is_some() + { + encoded = true; + break; + } + } + assert!(encoded, "background encode should complete"); + let text = buffer_text(term.backend().buffer()); + assert!(text.contains("Deleted")); + assert!(text.contains("32x32")); + } + + #[test] + fn iterm2_picker_loads_images_like_kitty() { + let repo = TempRepo::new("iterm2-picker"); + let bytes = colored_png([30, 30, 220], 20, 20); + repo.write("img.png", &bytes); + repo.git(&["add", "img.png"]); + repo.commit("init"); + let git = repo.git_commands(); + + for picker in [graphics_picker(), iterm2_picker()] { + assert_ne!(picker.protocol_type(), ProtocolType::Halfblocks); + let preview = ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + &picker, + ) + .expect("iterm2/kitty loads"); + // No global picker touched; injected picker drives both sides. + assert!(preview.old.is_some() && preview.new.is_some()); + assert_eq!( + preview.old.as_ref().unwrap().orig_width, + preview.new.as_ref().unwrap().orig_width + ); + } + } + + #[test] + fn diff_view_state_clears_preview_to_text_fallback() { + use crate::pager::side_by_side::DiffViewState; + + let picker = graphics_picker(); + let decoded = decode_bytes(&colored_png([200, 30, 30], 8, 8)).unwrap(); + let preview = ImagePreview::from_decoded(Some(decoded), None, &picker).unwrap(); + + // Setting a preview then loading text clears back to the fallback. + let mut state = DiffViewState::new(); + state.image_preview = Some(preview); + assert!(state.image_preview.is_some()); + state.load("file.txt", "old\n", "new\n"); + assert!( + state.image_preview.is_none(), + "load() must clear the image preview" + ); + assert!(!state.lines.is_empty(), "text fallback has lines"); + + let decoded2 = decode_bytes(&colored_png([30, 200, 30], 8, 8)).unwrap(); + state.image_preview = + Some(ImagePreview::from_decoded(Some(decoded2), None, &picker).unwrap()); + state.load_from_diff_output( + "file.txt", + "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n", + ); + assert!( + state.image_preview.is_none(), + "load_from_diff_output must clear the preview" + ); + assert!( + state.lines.iter().any( + |l| l.old_line.as_ref().is_some_and(|(_, t)| t.contains("old")) + || l.new_line.as_ref().is_some_and(|(_, t)| t.contains("new")) + ), + "fallback text diff is viewable" + ); + + // Parsed text without a preview stays a plain text diff. + let parsed = DiffViewState::parse_diff_output( + "file.txt", + "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n", + 4, + true, + ); + assert!(parsed.image_preview.is_none()); + let mut applied = DiffViewState::new(); + applied.apply_parsed(parsed); + assert!(applied.image_preview.is_none()); + assert!(!applied.lines.is_empty()); + } + + #[test] + fn load_one_side_edge_cases_are_safe() { + let repo = TempRepo::new("edge-cases"); + let bytes = colored_png([200, 30, 30], 8, 8); + repo.write("img.png", &bytes); + repo.git(&["add", "img.png"]); + repo.commit("init"); + std::fs::create_dir_all(repo.path.join("subdir")).expect("mkdir"); + let repo_path = repo.repo_path(); + + // Empty and NUL paths never touch git/disk beyond a failure. + assert!(load_one_side(&repo_path, ImageSource::Worktree("")).is_none()); + assert!(load_one_side(&repo_path, ImageSource::Worktree("a\0b")).is_none()); + assert!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD", + path: "", + } + ) + .is_none() + ); + assert!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD", + path: "a\0b", + } + ) + .is_none() + ); + assert!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD\0", + path: "img.png", + } + ) + .is_none() + ); + + // Missing worktree file and missing blob are absent, not failed. + assert!(matches!( + read_worktree_bytes(&repo_path, "missing.png"), + Ok(None) + )); + assert!(matches!( + load_one_side(&repo_path, ImageSource::Worktree("missing.png")), + Some(None) + )); + assert!(matches!( + read_revision_blob(&repo_path, "HEAD", "missing.png"), + RevisionRead::Absent + )); + assert!(matches!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD", + path: "missing.png", + } + ), + Some(None) + )); + + // Directories read as absent. + assert!(matches!( + read_worktree_bytes(&repo_path, "subdir"), + Ok(None) + )); + assert!(matches!( + load_one_side(&repo_path, ImageSource::Worktree("subdir")), + Some(None) + )); + + // Direct helper coverage for bounded reads. + assert!(matches!( + read_worktree_bytes(&repo_path, "img.png"), + Ok(Some(_)) + )); + assert!(matches!( + read_revision_blob(&repo_path, "HEAD", "img.png"), + RevisionRead::Present(_) + )); + } + #[test] + fn image_diff_renders_labels_and_hides_for_overlay() { + use crate::pager::side_by_side::{DiffViewState, render_diff}; + let picker = graphics_picker(); + let mut parsed = DiffViewState::parse_diff_output( + "image.png", + "Binary files a/image.png and b/image.png differ\n", + 4, + true, + ); + parsed.image_preview = ImagePreview::from_decoded( + Some(decode_bytes(&png_bytes(32, 32)).unwrap()), + Some(decode_bytes(&png_bytes(64, 64)).unwrap()), + &picker, + ); + assert!(parsed.image_preview.as_ref().unwrap().uses_placeholders()); + let mut view = DiffViewState::new(); + view.apply_parsed(parsed); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + let theme = Theme::default(); + terminal + .draw(|f| render_diff(f, f.area(), &mut view, &theme, true, false, true)) + .unwrap(); + let text = buffer_text(terminal.backend().buffer()); + assert!(text.contains("Before") && text.contains("After")); + assert!(!text.contains("Binary cannot be previewed")); + view.image_preview_hidden = true; + terminal + .draw(|f| render_diff(f, f.area(), &mut view, &theme, true, false, true)) + .unwrap(); + let text = buffer_text(terminal.backend().buffer()); + assert!(text.contains("Binary cannot be previewed")); + assert!(!text.contains("Before")); + } + + // ------------------------------------------------------------------ + // Inline mixed-diff helpers (isolated, no global env/detection). + // ------------------------------------------------------------------ + + fn mixed_three_file_diff() -> String { + let long_a_old = "a".repeat(200); + let long_a_new = "b".repeat(200); + let long_c_old = "c".repeat(200); + let long_c_new = "d".repeat(200); + format!( + "diff --git a/first.txt b/first.txt\n--- a/first.txt\n+++ b/first.txt\n@@ -1,3 +1,3 @@\n-{long_a_old}\n+{long_a_new}\n context line one\n@@ -10,3 +10,3 @@\n context line two\n-{long_c_old}\n+{long_c_new}\n context line three\ndiff --git a/img.png b/img.png\nBinary files a/img.png and b/img.png differ\ndiff --git a/last.txt b/last.txt\n--- a/last.txt\n+++ b/last.txt\n@@ -1,2 +1,2 @@\n-tail old one\n+tail new one\n tail context\n" + ) + } + + fn inline_ready_both_sides() -> InlineImagePreview { + let picker = graphics_picker(); + let old = decode_bytes(&png_bytes(32, 32)).unwrap(); + let new = decode_bytes(&png_bytes(32, 32)).unwrap(); + let preview = ImagePreview::from_decoded(Some(old), Some(new), &picker).unwrap(); + InlineImagePreview::from_preview_for_test(preview) + } + + fn inline_ready_single_sided(added: bool) -> InlineImagePreview { + let picker = graphics_picker(); + let img = decode_bytes(&png_bytes(16, 16)).unwrap(); + let (old, new) = if added { + (None, Some(img)) + } else { + (Some(img), None) + }; + let preview = ImagePreview::from_decoded(old, new, &picker).unwrap(); + InlineImagePreview::from_preview_for_test(preview) + } + + fn render_state_buffer( + state: &mut crate::pager::side_by_side::DiffViewState, + w: u16, + h: u16, + ) -> ratatui::buffer::Buffer { + use crate::pager::side_by_side::render_diff; + let mut term = Terminal::new(TestBackend::new(w, h)).unwrap(); + term.draw(|f| { + render_diff( + f, + f.area(), + state, + &crate::config::Theme::default(), + true, + false, + true, + ) + }) + .unwrap(); + term.backend().buffer().clone() + } + + fn test_row_string(buf: &ratatui::buffer::Buffer, y: u16) -> String { + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol().to_string()) + .collect() + } + + fn test_header_idx(state: &crate::pager::side_by_side::DiffViewState, name: &str) -> usize { + state + .lines + .iter() + .position(|l| l.file_header.as_ref().is_some_and(|h| h.contains(name))) + .expect("header present") + } + + fn middle_image_section(state: &crate::pager::side_by_side::ParsedDiff) -> usize { + state + .lines + .iter() + .find_map(|l| { + if l.preview_placeholder.is_some() { + Some(l.section_index) + } else { + None + } + }) + .expect("binary section present") + } + + #[test] + fn inline_with_picker_and_ready_fixture_uses_injected_picker() { + let picker = graphics_picker(); + let idle = InlineImagePreview::with_picker( + std::path::Path::new("."), + InlineImageSource::Missing, + InlineImageSource::Missing, + picker, + ); + assert!(matches!(idle.state, InlineLoadState::Idle)); + let bytes = png_bytes(4, 4); + assert!(!bytes.is_empty()); + let decoded = decode_bytes(&bytes).expect("png decodes"); + assert_eq!((decoded.width(), decoded.height()), (4, 4)); + let picker2 = graphics_picker(); + let d = decode_bytes(&png_bytes(8, 8)).unwrap(); + let preview = ImagePreview::from_decoded(Some(d), None, &picker2).unwrap(); + let ready = InlineImagePreview::from_preview_for_test(preview); + assert!(matches!(ready.state, InlineLoadState::Ready(_))); + assert!(ready.estimated_bytes() > idle.estimated_bytes()); + use crate::pager::side_by_side::DiffViewState; + let diff = mixed_three_file_diff(); + let mut parsed = DiffViewState::parse_diff_output("mixed", &diff, 4, true); + let middle = middle_image_section(&parsed); + let mut map = std::collections::HashMap::new(); + map.insert(middle, inline_ready_both_sides()); + let before = parsed.lines.len(); + parsed.attach_inline_images(map); + assert_eq!(parsed.inline_images.len(), 1); + assert!(parsed.lines.len() > before); + assert_eq!( + parsed + .lines + .iter() + .filter(|l| l.preview_placeholder.is_some() && l.section_index == middle) + .count(), + INLINE_IMAGE_ROWS + ); + } + + #[test] + fn inline_lazy_idle_has_no_pixels() { + let picker = graphics_picker(); + let inline = InlineImagePreview::with_picker( + std::path::Path::new("."), + InlineImageSource::Missing, + InlineImageSource::Missing, + picker, + ); + assert!(matches!(inline.state, InlineLoadState::Idle)); + let est = inline.estimated_bytes(); + assert!(est < 4096, "idle estimate small, got {est}"); + let ready = inline_ready_both_sides(); + assert!(ready.estimated_bytes() > est + 1000); + } + + #[test] + fn inline_initial_render_triggers_background_load() { + use crate::pager::side_by_side::DiffSideView; + let repo = TempRepo::new("inline-initial-load"); + let bytes = png_bytes(16, 16); + repo.write("img.png", &bytes); + let repo_path = repo.repo_path(); + let picker = graphics_picker(); + let mut inline = InlineImagePreview::with_picker( + &repo_path, + InlineImageSource::Missing, + InlineImageSource::Worktree("img.png".to_string()), + picker, + ); + assert!(matches!(inline.state, InlineLoadState::Idle)); + let theme = crate::config::Theme::default(); + let mut term = Terminal::new(TestBackend::new(60, 14)).unwrap(); + let start = std::time::Instant::now(); + term.draw(|f| { + inline.render(f, f.area(), DiffSideView::Both, &theme); + }) + .unwrap(); + assert!( + start.elapsed() < std::time::Duration::from_secs(2), + "initial render must not block" + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut settled = false; + while std::time::Instant::now() < deadline { + term.draw(|f| { + inline.render(f, f.area(), DiffSideView::Both, &theme); + }) + .unwrap(); + match &inline.state { + InlineLoadState::Ready(_) => { + settled = true; + break; + } + InlineLoadState::Unavailable => panic!("valid png must not become unavailable"), + _ => {} + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(settled, "background load should settle to Ready within 2s"); + assert!(inline.estimated_bytes() > 4096); + } + + #[test] + fn inline_corrupt_source_shows_fallback_without_blocking() { + use crate::pager::side_by_side::DiffSideView; + let repo = TempRepo::new("inline-corrupt-fallback"); + repo.write("img.png", b"definitely not an image"); + let repo_path = repo.repo_path(); + let picker = graphics_picker(); + let mut inline = InlineImagePreview::with_picker( + &repo_path, + InlineImageSource::Missing, + InlineImageSource::Worktree("img.png".to_string()), + picker, + ); + let theme = crate::config::Theme::default(); + let mut term = Terminal::new(TestBackend::new(60, 14)).unwrap(); + let start = std::time::Instant::now(); + term.draw(|f| { + inline.render(f, f.area(), DiffSideView::Both, &theme); + }) + .unwrap(); + assert!( + start.elapsed() < std::time::Duration::from_secs(2), + "corrupt render must not block" + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut unavailable = false; + while std::time::Instant::now() < deadline { + term.draw(|f| { + inline.render(f, f.area(), DiffSideView::Both, &theme); + }) + .unwrap(); + if matches!(inline.state, InlineLoadState::Unavailable) { + unavailable = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(unavailable, "corrupt must settle to Unavailable"); + let text = buffer_text(term.backend().buffer()); + assert!( + text.contains("Binary cannot be previewed") || text.contains("╱"), + "fallback stripes, got {text:?}" + ); + assert!(inline.estimated_bytes() < 4096); + } + + #[test] + fn inline_pending_unload_retains_loading_receiver() { + let picker = graphics_picker(); + let mut inline = InlineImagePreview::with_picker( + std::path::Path::new("."), + InlineImageSource::Missing, + InlineImageSource::Missing, + picker, + ); + let (_tx, rx) = std::sync::mpsc::channel::>(); + inline.state = InlineLoadState::Loading(rx); + inline.unload(); + assert!( + matches!(inline.state, InlineLoadState::Loading(_)), + "pending load must be retained" + ); + let picker2 = graphics_picker(); + let mut inline2 = InlineImagePreview::with_picker( + std::path::Path::new("."), + InlineImageSource::Missing, + InlineImageSource::Missing, + picker2, + ); + let (tx2, rx2) = std::sync::mpsc::channel::>(); + tx2.send(None).unwrap(); + inline2.state = InlineLoadState::Loading(rx2); + inline2.unload(); + assert!( + matches!(inline2.state, InlineLoadState::Unavailable), + "completed None must become Unavailable" + ); + } + + #[test] + fn inline_ready_unload_and_cache_unload_free_pixels() { + let mut ready = inline_ready_both_sides(); + assert!(matches!(ready.state, InlineLoadState::Ready(_))); + let before = ready.estimated_bytes(); + assert!(before > 4096, "ready carries pixels, got {before}"); + ready.unload(); + assert!(matches!(ready.state, InlineLoadState::Idle)); + assert!(ready.estimated_bytes() < before); + assert!(ready.estimated_bytes() < 4096); + let mut cached = inline_ready_both_sides(); + cached.unload_for_cache(); + assert!(matches!(cached.state, InlineLoadState::Idle)); + assert!(cached.estimated_bytes() < 4096); + let picker = graphics_picker(); + let mut unavailable = InlineImagePreview::with_picker( + std::path::Path::new("."), + InlineImageSource::Missing, + InlineImageSource::Missing, + picker, + ); + unavailable.state = InlineLoadState::Unavailable; + unavailable.unload(); + assert!(matches!(unavailable.state, InlineLoadState::Unavailable)); + unavailable.unload_for_cache(); + assert!(matches!(unavailable.state, InlineLoadState::Unavailable)); + } + + #[test] + fn inline_offscreen_stays_idle_until_viewport() { + use crate::pager::side_by_side::{DiffViewLayout, DiffViewState, render_diff}; + let repo = TempRepo::new("inline-offscreen-idle"); + let bytes = png_bytes(16, 16); + repo.write("img.png", &bytes); + let repo_path = repo.repo_path(); + let diff = mixed_three_file_diff(); + let mut parsed = DiffViewState::parse_diff_output("mixed", &diff, 4, true); + let middle = middle_image_section(&parsed); + let picker = graphics_picker(); + let inline = InlineImagePreview::with_picker( + &repo_path, + InlineImageSource::Missing, + InlineImageSource::Worktree("img.png".to_string()), + picker, + ); + assert!(matches!(inline.state, InlineLoadState::Idle)); + let mut map = std::collections::HashMap::new(); + map.insert(middle, inline); + parsed.attach_inline_images(map); + let mut state = DiffViewState::new(); + state.apply_parsed(parsed); + state.view_layout = DiffViewLayout::SideBySide; + state.wrap = false; + state.scroll_offset = 0; + let img_hdr = test_header_idx(&state, "img.png"); + assert!(img_hdr >= 6, "image below small viewport, got {img_hdr}"); + let theme = crate::config::Theme::default(); + let mut term = Terminal::new(TestBackend::new(60, 8)).unwrap(); + term.draw(|f| { + render_diff(f, f.area(), &mut state, &theme, true, false, true); + }) + .unwrap(); + assert!( + matches!( + state.inline_images.get(&middle).unwrap().state, + InlineLoadState::Idle + ), + "offscreen must stay Idle" + ); + state.scroll_offset = img_hdr; + let mut term2 = Terminal::new(TestBackend::new(60, 20)).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut started = false; + while std::time::Instant::now() < deadline { + term2 + .draw(|f| { + render_diff(f, f.area(), &mut state, &theme, true, false, true); + }) + .unwrap(); + if !matches!( + state.inline_images.get(&middle).unwrap().state, + InlineLoadState::Idle + ) { + started = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(started, "viewport render must start load"); + } + + #[test] + fn inline_mixed_three_file_sticky_headers_and_bounded_image() { + use crate::pager::side_by_side::{DiffViewLayout, DiffViewState}; + for wrap in [false, true] { + for layout in [DiffViewLayout::SideBySide, DiffViewLayout::Unified] { + let diff = mixed_three_file_diff(); + let mut parsed = DiffViewState::parse_diff_output("mixed", &diff, 4, true); + let middle = middle_image_section(&parsed); + let mut map = std::collections::HashMap::new(); + map.insert(middle, inline_ready_both_sides()); + parsed.attach_inline_images(map); + assert_eq!( + parsed + .lines + .iter() + .filter(|l| l.preview_placeholder.is_some() && l.section_index == middle) + .count(), + INLINE_IMAGE_ROWS, + "layout {layout:?} wrap {wrap}" + ); + let mut state = DiffViewState::new(); + state.apply_parsed(parsed); + state.wrap = wrap; + let img_hdr = test_header_idx(&state, "img.png"); + let tail_hdr = test_header_idx(&state, "last.txt"); + assert!(tail_hdr > img_hdr, "tail after image"); + if layout == DiffViewLayout::Unified { + state.view_layout = DiffViewLayout::Unified; + state.scroll_offset = 0; + let _ = render_state_buffer(&mut state, 60, 30); + state.view_layout = DiffViewLayout::SideBySide; + state.scroll_offset = img_hdr + 1; + let _ = render_state_buffer(&mut state, 60, 30); + state.toggle_view_layout(); + assert_eq!(state.view_layout, DiffViewLayout::Unified); + } else { + state.view_layout = DiffViewLayout::SideBySide; + let _ = render_state_buffer(&mut state, 60, 30); + state.scroll_offset = img_hdr + 1; + } + assert!( + state + .sticky_file_header() + .is_some_and(|h| h.contains("img.png")), + "layout {layout:?} wrap {wrap} sticky" + ); + assert_eq!(state.sticky_header_line_idx(), Some(img_hdr)); + let buf = render_state_buffer(&mut state, 60, 30); + let text = buffer_text(&buf); + assert!( + text.contains("Before") && text.contains("After"), + "layout {layout:?} wrap {wrap} labels, got {text:?}" + ); + assert!(text.contains("last.txt"), "tail header visible"); + assert!(text.contains("tail"), "tail content visible"); + let mut before_y = None; + let mut tail_y = None; + for y in 0..buf.area.height { + let row = test_row_string(&buf, y); + if row.contains("Before") && before_y.is_none() { + before_y = Some(y); + } + if row.contains("last.txt") && tail_y.is_none() { + tail_y = Some(y); + } + } + let (b, t) = (before_y.expect("Before row"), tail_y.expect("tail row")); + assert!(b < t, "image above tail layout {layout:?} wrap {wrap}"); + assert!( + (t as usize).saturating_sub(b as usize) <= INLINE_IMAGE_ROWS + 4, + "tail just after image b={b} t={t}" + ); + for y in (t + 1)..buf.area.height { + let row = test_row_string(&buf, y); + assert!( + !row.contains("Before") && !row.contains("After"), + "bounded y={y} {row:?}" + ); + } + if layout == DiffViewLayout::Unified { + state.toggle_view_layout(); + assert_eq!(state.view_layout, DiffViewLayout::SideBySide); + assert!( + (state.scroll_offset as isize - (img_hdr as isize + 1)).abs() <= 2, + "toggle back near image, got {}", + state.scroll_offset + ); + } + } + } + } + + #[test] + fn inline_mixed_added_deleted_full_width_and_scroll_unloads() { + use crate::pager::side_by_side::{DiffSideView, DiffViewLayout, DiffViewState}; + for added in [true, false] { + let diff = mixed_three_file_diff(); + let mut parsed = DiffViewState::parse_diff_output("mixed", &diff, 4, true); + let middle = middle_image_section(&parsed); + let mut map = std::collections::HashMap::new(); + map.insert(middle, inline_ready_single_sided(added)); + parsed.attach_inline_images(map); + let mut state = DiffViewState::new(); + state.apply_parsed(parsed); + state.view_layout = DiffViewLayout::SideBySide; + state.side_view = DiffSideView::Both; + state.wrap = false; + let img_hdr = test_header_idx(&state, "img.png"); + state.scroll_offset = img_hdr + 1; + let buf = render_state_buffer(&mut state, 60, 30); + let text = buffer_text(&buf); + let label = if added { "Added" } else { "Deleted" }; + assert!(text.contains(label), "added={added} label, got {text:?}"); + assert!( + !text.contains("Before") && !text.contains("After"), + "single-sided no Before/After" + ); + let tail_hdr = test_header_idx(&state, "last.txt"); + state.scroll_offset = tail_hdr; + let buf2 = render_state_buffer(&mut state, 60, 30); + let text2 = buffer_text(&buf2); + assert!( + !text2.contains("Before") && !text2.contains("After"), + "no labels past image" + ); + assert!( + !text2.contains(label), + "label {label} gone after scroll, got {text2:?}" + ); + let preview = state.inline_images.get(&middle).unwrap(); + assert!( + matches!(preview.state, InlineLoadState::Idle), + "scrolled past must unload to Idle" + ); + assert!(preview.estimated_bytes() < 4096); + } + } + + #[test] + fn inline_concurrent_loads_settle_without_global_counter() { + use crate::pager::side_by_side::DiffSideView; + let repo = TempRepo::new("inline-concurrent-loads"); + for (name, color) in [ + ("a.png", [200, 30, 30]), + ("b.png", [30, 200, 30]), + ("c.png", [30, 30, 200]), + ] { + repo.write(name, &colored_png(color, 12, 12)); + } + let repo_path = repo.repo_path(); + let picker = graphics_picker(); + let mut inlines: Vec = ["a.png", "b.png", "c.png"] + .into_iter() + .map(|n| { + InlineImagePreview::with_picker( + &repo_path, + InlineImageSource::Missing, + InlineImageSource::Worktree(n.to_string()), + picker.clone(), + ) + }) + .collect(); + let theme = crate::config::Theme::default(); + let mut term = Terminal::new(TestBackend::new(60, 14)).unwrap(); + let start = std::time::Instant::now(); + for inline in inlines.iter_mut() { + term.draw(|f| { + inline.render(f, f.area(), DiffSideView::Both, &theme); + }) + .unwrap(); + } + assert!( + start.elapsed() < std::time::Duration::from_secs(2), + "kickoff must not block" + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + let mut all_ready = true; + for inline in inlines.iter_mut() { + term.draw(|f| { + inline.render(f, f.area(), DiffSideView::Both, &theme); + }) + .unwrap(); + match &inline.state { + InlineLoadState::Ready(_) => {} + InlineLoadState::Unavailable => panic!("valid png unavailable"), + _ => { + all_ready = false; + } + } + } + if all_ready { + break; + } + if std::time::Instant::now() >= deadline { + panic!("concurrent loads should settle within 2s"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } +} diff --git a/src/pager/image_preview/capabilities.rs b/src/pager/image_preview/capabilities.rs new file mode 100644 index 0000000..9c0e339 --- /dev/null +++ b/src/pager/image_preview/capabilities.rs @@ -0,0 +1,488 @@ +//! Terminal capability detection and the shared image protocol picker. + +use std::io::Write; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use ratatui_image::picker::cap_parser::{Parser, Response}; +use ratatui_image::picker::{Picker, ProtocolType}; + +const PROBE_TIMEOUT: Duration = Duration::from_millis(450); +// Only CSI replies: Kitty/Ghostty are identified by hints, not an APC query. +// Graphics replies can arrive after the status reply and leak into key input. +const CAPABILITY_QUERY: &str = "\x1b[c\x1b[16t\x1b[5n"; +const FALLBACK_KITTY_FONT: (u16, u16) = (8, 16); + +static GLOBAL_PICKER: OnceLock> = OnceLock::new(); + +pub(super) fn global_picker_ref() -> Option<&'static Picker> { + GLOBAL_PICKER.get().and_then(|o| o.as_ref()) +} + +/// Detect capabilities once, before the TUI input reader starts. Only the +/// first call probes; later calls are no-ops. Never touches stdin/stdout, +/// never mutates tmux settings. +pub fn initialize() { + if GLOBAL_PICKER.get().is_some() { + return; + } + let picker = detect_picker(); + let _ = GLOBAL_PICKER.set(picker); +} + +fn preview_disabled_by_env() -> bool { + match std::env::var("LAZYGITRS_IMAGE_PREVIEW") { + Ok(v) => { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "off" | "0" | "false" | "no" | "disable" | "disabled" | "none" + ) + } + Err(_) => false, + } +} + +fn in_unsupported_multiplexer() -> bool { + if std::env::var_os("TMUX").is_some() { + return true; + } + if std::env::var_os("ZELLIJ").is_some() { + return true; + } + if std::env::var_os("ZELLIJ_SESSION_NAME").is_some() { + return true; + } + if std::env::var("TERM").is_ok_and(|t| t.starts_with("tmux") || t.starts_with("screen")) { + return true; + } + if std::env::var("TERM_PROGRAM").is_ok_and(|p| p == "tmux") { + return true; + } + false +} + +fn is_konsole() -> bool { + if std::env::var_os("KONSOLE_VERSION").is_some() { + return true; + } + if std::env::var_os("KONSOLE_DBUS_SESSION").is_some() { + return true; + } + if std::env::var("TERM").is_ok_and(|t| t.to_ascii_lowercase().contains("konsole")) { + return true; + } + false +} + +/// iTerm2/WezTerm hints. `rio` is known-broken and `Hyper` is unverified, so +/// neither enables graphics here. +fn iterm2_hint_from_env() -> bool { + if let Ok(tp) = std::env::var("TERM_PROGRAM") { + if tp.contains("iTerm") + || tp.contains("WezTerm") + || tp.contains("mintty") + || tp.contains("vscode") + || tp.contains("Tabby") + { + return true; + } + } + if std::env::var("LC_TERMINAL").is_ok_and(|v| v.contains("iTerm")) { + return true; + } + if std::env::var("ITERM_SESSION_ID").is_ok_and(|s| !s.is_empty()) { + return true; + } + if std::env::var("WEZTERM_EXECUTABLE").is_ok_and(|s| !s.is_empty()) { + return true; + } + false +} + +fn strong_kitty_ghostty_hint() -> bool { + if std::env::var("KITTY_WINDOW_ID").is_ok_and(|s| !s.is_empty()) { + return true; + } + if std::env::var("TERM").is_ok_and(|t| t == "xterm-kitty" || t.contains("kitty")) { + return true; + } + if std::env::var("TERM_PROGRAM") + .is_ok_and(|p| p.contains("kitty") || p.contains("ghostty") || p.contains("Ghostty")) + { + return true; + } + if std::env::var_os("GHOSTTY_RESOURCES_DIR").is_some() { + return true; + } + if std::env::var("GHOSTTY_BIN_DIR").is_ok_and(|s| !s.is_empty()) { + return true; + } + false +} + +#[cfg(unix)] +fn font_size_from_fd(fd: std::os::fd::RawFd) -> Option<(u16, u16)> { + // SAFETY: ioctl with TIOCGWINSZ on a borrowed fd; `ws` is a plain POD out-param. + let mut ws: libc::winsize = unsafe { std::mem::zeroed() }; + let ret = unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut ws) }; + if ret != 0 { + return None; + } + if ws.ws_col == 0 || ws.ws_row == 0 || ws.ws_xpixel == 0 || ws.ws_ypixel == 0 { + return None; + } + let w = ws.ws_xpixel / ws.ws_col; + let h = ws.ws_ypixel / ws.ws_row; + if w == 0 || h == 0 { + return None; + } + if !(4..=100).contains(&w) || !(6..=200).contains(&h) { + return None; + } + Some((w, h)) +} + +#[cfg(unix)] +fn font_size_from_winsize() -> Option<(u16, u16)> { + use std::os::fd::AsRawFd; + if let Ok(f) = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + { + if let Some(fs) = font_size_from_fd(f.as_raw_fd()) { + return Some(fs); + } + } + font_size_from_fd(libc::STDOUT_FILENO) +} + +#[cfg(not(unix))] +fn font_size_from_winsize() -> Option<(u16, u16)> { + None +} + +/// Bounded synchronous capability probe on `/dev/tty`. No threads, no +/// detached readers; termios is restored via a guard. +#[cfg(unix)] +fn probe_tty_responses(timeout: Duration) -> Option> { + use std::os::fd::AsRawFd; + + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .ok()?; + let fd = file.as_raw_fd(); + + // SAFETY: tcgetattr on our own open tty fd with a valid out-pointer. + let mut orig: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(fd, &mut orig) } != 0 { + return None; + } + struct Guard { + fd: i32, + orig: libc::termios, + } + impl Drop for Guard { + fn drop(&mut self) { + unsafe { + libc::tcsetattr(self.fd, libc::TCSANOW, &self.orig); + } + } + } + let _guard = Guard { fd, orig }; + + // SAFETY: tcsetattr on our own tty fd. + let mut raw = _guard.orig; + raw.c_lflag &= !(libc::ICANON | libc::ECHO); + raw.c_cc[libc::VMIN as usize] = 0; + raw.c_cc[libc::VTIME as usize] = 0; + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 { + return None; + } + + { + let mut w = &file; + if w.write_all(CAPABILITY_QUERY.as_bytes()).is_err() { + return None; + } + if w.flush().is_err() { + return None; + } + } + + let mut parser = Parser::new(); + let mut out: Vec = Vec::new(); + let deadline = Instant::now() + timeout; + let mut buf = [0u8; 512]; + + loop { + let remain = deadline.saturating_duration_since(Instant::now()); + if remain.is_zero() { + break; + } + let ms = remain.as_millis().min(100) as libc::c_int; + let mut pfd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: poll on our own tty fd with a valid pollfd pointer. + let pr = unsafe { libc::poll(&mut pfd, 1, ms) }; + if pr < 0 { + continue; + } + if pr == 0 { + continue; + } + if pfd.revents & libc::POLLIN == 0 { + if pfd.revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0 { + break; + } + continue; + } + // SAFETY: read into a valid stack buffer. + let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; + if n <= 0 { + continue; + } + for b in buf.iter().take(n as usize) { + for r in parser.push(char::from(*b)) { + if r == Response::Status { + return Some(out); + } + out.push(r); + } + } + if out.len() > 32 { + break; + } + } + if out.is_empty() { None } else { Some(out) } +} + +#[cfg(not(unix))] +fn probe_tty_responses(_timeout: Duration) -> Option> { + None +} + +fn interpret_probed( + responses: Option>, +) -> (Option, Option<(u16, u16)>) { + let Some(responses) = responses else { + return (None, None); + }; + let mut proto: Option = None; + let mut font: Option<(u16, u16)> = None; + for r in &responses { + match r { + Response::Kitty => { + proto = Some(ProtocolType::Kitty); + } + Response::Sixel => { + if proto.is_none() { + proto = Some(ProtocolType::Sixel); + } + } + Response::CellSize(Some((w, h))) => { + if *w != 0 && *h != 0 { + font = Some((*w, *h)); + } + } + Response::CellSize(None) => {} + Response::RectangularOps | Response::CursorPositionReport(..) | Response::Status => {} + } + } + (proto, font) +} + +fn detect_picker() -> Option { + if preview_disabled_by_env() { + return None; + } + if in_unsupported_multiplexer() { + return None; + } + if is_konsole() { + return None; + } + // Non-placeholder protocols cannot reliably clear graphics under popups + // with this widget release. Keep them opt-in until verified end-to-end. + let experimental = std::env::var("LAZYGITRS_IMAGE_PREVIEW").is_ok_and(|v| v == "experimental"); + if !strong_kitty_ghostty_hint() && !experimental { + return None; + } + + #[cfg(not(unix))] + { + return None; + } + + #[cfg(unix)] + { + let nested = crate::os::tty::nested_tty_launch(); + let winsize_font = font_size_from_winsize(); + + if nested { + // No terminal query under nested launches; hints + ioctl only. + if experimental && iterm2_hint_from_env() { + let fs = winsize_font?; + let mut p = Picker::from_fontsize(fs); + p.set_protocol_type(ProtocolType::Iterm2); + return Some(p); + } + if strong_kitty_ghostty_hint() { + let fs = winsize_font.unwrap_or(FALLBACK_KITTY_FONT); + if fs.0 == 0 || fs.1 == 0 { + return None; + } + let mut p = Picker::from_fontsize(fs); + p.set_protocol_type(ProtocolType::Kitty); + return Some(p); + } + return None; + } + + // Known placeholder terminals need no protocol query. In particular, + // don't create terminal replies when ioctl already supplies cell size. + let probed = if strong_kitty_ghostty_hint() && winsize_font.is_some() { + None + } else { + probe_tty_responses(PROBE_TIMEOUT) + }; + let (probed_proto, probed_font) = interpret_probed(probed); + let hint_iterm2 = experimental && iterm2_hint_from_env(); + let hint_kitty = strong_kitty_ghostty_hint(); + + // iTerm2 hints win, then queried Sixel, then Kitty only with a strong + // Kitty/Ghostty hint. A bare Kitty query response is not trusted: our + // Kitty path needs unicode placeholders, which the query alone does + // not guarantee. + let proto: Option = if hint_iterm2 { + Some(ProtocolType::Iterm2) + } else if let Some(p) = probed_proto { + match p { + ProtocolType::Sixel if experimental => Some(ProtocolType::Sixel), + ProtocolType::Kitty if hint_kitty => Some(ProtocolType::Kitty), + _ => { + if hint_kitty { + Some(ProtocolType::Kitty) + } else { + None + } + } + } + } else if hint_kitty { + Some(ProtocolType::Kitty) + } else { + None + }; + + let proto = proto?; + if proto == ProtocolType::Halfblocks { + return None; + } + + let font = winsize_font.or(probed_font).or_else(|| { + if proto == ProtocolType::Kitty && hint_kitty { + Some(FALLBACK_KITTY_FONT) + } else { + None + } + })?; + if font.0 == 0 || font.1 == 0 { + return None; + } + + let mut picker = Picker::from_fontsize(font); + picker.set_protocol_type(proto); + if picker.protocol_type() == ProtocolType::Halfblocks { + return None; + } + Some(picker) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn startup_query_never_requests_a_graphics_reply() { + assert_eq!(CAPABILITY_QUERY, "\x1b[c\x1b[16t\x1b[5n"); + assert!(!CAPABILITY_QUERY.contains("\x1b_G")); + assert!(!CAPABILITY_QUERY.contains("i=31")); + } + + #[test] + fn interpret_probed_without_responses_has_no_protocol_or_font() { + assert_eq!(interpret_probed(None), (None, None)); + assert_eq!(interpret_probed(Some(Vec::new())), (None, None)); + } + + #[test] + fn interpret_probed_recognizes_sixel() { + assert_eq!( + interpret_probed(Some(vec![Response::Sixel])), + (Some(ProtocolType::Sixel), None) + ); + } + + #[test] + fn interpret_probed_prefers_kitty_regardless_of_response_order() { + for responses in [ + vec![Response::Kitty, Response::Sixel], + vec![Response::Sixel, Response::Kitty], + ] { + assert_eq!( + interpret_probed(Some(responses)), + (Some(ProtocolType::Kitty), None) + ); + } + } + + #[test] + fn interpret_probed_recognizes_cell_size_without_a_protocol() { + assert_eq!( + interpret_probed(Some(vec![Response::CellSize(Some((8, 16)))])), + (None, Some((8, 16))) + ); + } + + #[test] + fn interpret_probed_ignores_missing_and_zero_cell_sizes() { + assert_eq!( + interpret_probed(Some(vec![ + Response::CellSize(None), + Response::CellSize(Some((0, 16))), + Response::CellSize(Some((8, 0))), + Response::CellSize(Some((0, 0))), + ])), + (None, None) + ); + assert_eq!( + interpret_probed(Some(vec![ + Response::CellSize(Some((8, 16))), + Response::CellSize(None), + Response::CellSize(Some((0, 16))), + Response::CellSize(Some((8, 0))), + ])), + (None, Some((8, 16))) + ); + } + + #[test] + fn interpret_probed_keeps_last_valid_font_and_ignores_other_responses() { + assert_eq!( + interpret_probed(Some(vec![ + Response::Sixel, + Response::CellSize(Some((8, 16))), + Response::RectangularOps, + Response::CellSize(Some((10, 20))), + Response::Status, + ])), + (Some(ProtocolType::Sixel), Some((10, 20))) + ); + } +} diff --git a/src/pager/image_preview/inline_diff.rs b/src/pager/image_preview/inline_diff.rs new file mode 100644 index 0000000..d47abe4 --- /dev/null +++ b/src/pager/image_preview/inline_diff.rs @@ -0,0 +1,174 @@ +//! Select inline raster sections and their source identities without loading pixels. + +use std::collections::HashSet; +use std::path::Path; + +use super::{InlineImagePreview, InlineImageSource}; +use crate::pager::side_by_side::ParsedDiff; + +/// Attach source metadata, not pixels. Inline sections decode on viewport entry. +pub(crate) fn attach_inline_image_previews( + parsed: &mut ParsedDiff, + repo: &Path, + diff: &str, + old_revision: &str, + new_revision: Option<&str>, + excluded_paths: &HashSet, +) { + let images = sources_for_diff(diff, old_revision, new_revision, excluded_paths) + .into_iter() + .filter_map(|(section, old, new)| { + InlineImagePreview::new(repo, old, new).map(|image| (section, image)) + }) + .collect(); + parsed.attach_inline_images(images); +} + +fn sources_for_diff( + diff: &str, + old_revision: &str, + new_revision: Option<&str>, + excluded_paths: &HashSet, +) -> Vec<(usize, InlineImageSource, InlineImageSource)> { + let mut sources = Vec::new(); + for (section, (_, file_diff)) in crate::pager::side_by_side::parse_multi_file_diff(diff) + .iter() + .enumerate() + { + let Some(paths) = crate::git::diff_paths::paths_from_diff(file_diff) else { + continue; + }; + if paths + .old + .iter() + .chain(&paths.new) + .any(|p| excluded_paths.contains(p)) + { + continue; + } + if !paths.old.iter().chain(&paths.new).any(|p| is_image_path(p)) { + continue; + } + // No textual hunk data may be replaced by graphics (attributes can + // force a PNG-looking file to be diffed as ordinary source text). + if file_diff.lines().any(|line| line.starts_with("@@")) { + continue; + } + let old = paths.old.map_or(InlineImageSource::Missing, |path| { + InlineImageSource::Revision { + revision: old_revision.to_string(), + path, + } + }); + let new = paths + .new + .map_or(InlineImageSource::Missing, |path| match new_revision { + Some(revision) => InlineImageSource::Revision { + revision: revision.to_string(), + path, + }, + None => InlineImageSource::Worktree(path), + }); + sources.push((section, old, new)); + } + sources +} + +pub(crate) fn is_image_path(path: &str) -> bool { + std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| { + matches!( + e.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tif" | "tiff" + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn revision(revision: &str, path: &str) -> InlineImageSource { + InlineImageSource::Revision { + revision: revision.into(), + path: path.into(), + } + } + + #[test] + fn raster_extensions_are_case_insensitive_and_exclude_svg_and_video() { + for extension in [ + "png", "JPG", "jpeg", "gif", "webp", "bmp", "ico", "tif", "TIFF", + ] { + assert!(is_image_path(&format!("folder/image.{extension}"))); + } + for path in ["image.svg", "movie.mp4", "image.png.txt", "png", ""] { + assert!(!is_image_path(path)); + } + } + + #[test] + fn renamed_images_use_distinct_paths_and_revision_sources() { + let diff = "diff --git a/old.png b/new.png\nsimilarity index 100%\nrename from old.png\nrename to new.png\n"; + assert_eq!( + sources_for_diff(diff, "base", Some("tip"), &HashSet::new()), + vec![(0, revision("base", "old.png"), revision("tip", "new.png"))] + ); + assert_eq!( + sources_for_diff(diff, "HEAD", None, &HashSet::new()), + vec![( + 0, + revision("HEAD", "old.png"), + InlineImageSource::Worktree("new.png".into()) + )] + ); + } + + #[test] + fn added_and_deleted_images_keep_missing_sides_and_section_indices() { + let diff = concat!( + "diff --git a/readme.txt b/readme.txt\nBinary files a/readme.txt and b/readme.txt differ\n", + "diff --git a/added.png b/added.png\nnew file mode 100644\nBinary files /dev/null and b/added.png differ\n", + "diff --git a/deleted.jpg b/deleted.jpg\ndeleted file mode 100644\nBinary files a/deleted.jpg and /dev/null differ\n", + ); + assert_eq!( + sources_for_diff(diff, "HEAD", Some(""), &HashSet::new()), + vec![ + (1, InlineImageSource::Missing, revision("", "added.png")), + ( + 2, + revision("HEAD", "deleted.jpg"), + InlineImageSource::Missing + ), + ] + ); + } + + #[test] + fn excludes_either_side_of_a_rename() { + let diff = "diff --git a/old.png b/new.png\nrename from old.png\nrename to new.png\n"; + for excluded in ["old.png", "new.png"] { + let excluded_paths = HashSet::from([excluded.to_string()]); + assert!(sources_for_diff(diff, "HEAD", None, &excluded_paths).is_empty()); + } + } + + #[test] + fn textual_image_hunks_are_never_replaced_and_later_indices_stay_aligned() { + let diff = concat!( + "diff --git a/text.png b/text.png\n--- a/text.png\n+++ b/text.png\n@@ -1 +1 @@\n-old\n+new\n", + "diff --git a/real.png b/real.png\nBinary files a/real.png and b/real.png differ\n", + ); + assert_eq!( + sources_for_diff(diff, "HEAD", None, &HashSet::new()), + vec![( + 1, + revision("HEAD", "real.png"), + InlineImageSource::Worktree("real.png".into()) + )] + ); + assert!(sources_for_diff("", "HEAD", None, &HashSet::new()).is_empty()); + } +} diff --git a/src/pager/image_preview/source.rs b/src/pager/image_preview/source.rs new file mode 100644 index 0000000..1f60142 --- /dev/null +++ b/src/pager/image_preview/source.rs @@ -0,0 +1,234 @@ +//! Bounded worktree/revision reads and decoding for image previews. + +use std::io::Read; +use std::path::Path; + +use image::{DynamicImage, ImageFormat}; + +pub(super) const MAX_COMPRESSED_BYTES: usize = 20 * 1024 * 1024; +pub(super) const MAX_IMAGE_DIMENSION: u32 = 16384; +const MAX_ALLOC_BYTES: u64 = 64 * 1024 * 1024; +pub(super) const PREVIEW_MAX_WIDTH: u32 = 1600; +pub(super) const PREVIEW_MAX_HEIGHT: u32 = 1200; + +/// Where one side of the before/after preview comes from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageSource<'a> { + /// Worktree file at this repo-relative path. + Worktree(&'a str), + /// `revision:path` via `git cat-file`. + Revision { revision: &'a str, path: &'a str }, + /// Legitimately absent (added/deleted). + Missing, +} + +/// Decoded + downsized image with its detected format. +#[derive(Debug, Clone)] +pub(crate) struct DecodedImage { + /// Downsized to `PREVIEW_MAX_*` (aspect preserved). + pub(crate) image: DynamicImage, + pub(crate) format: Option, + pub(crate) orig_width: u32, + pub(crate) orig_height: u32, +} + +impl DecodedImage { + pub(super) fn width(&self) -> u32 { + self.image.width() + } + pub(super) fn height(&self) -> u32 { + self.image.height() + } + pub(super) fn format_label(&self) -> &'static str { + format_label_for(self.format) + } +} + +pub(super) fn format_label_for(format: Option) -> &'static str { + match format { + Some(ImageFormat::Png) => "PNG", + Some(ImageFormat::Jpeg) => "JPEG", + Some(ImageFormat::Gif) => "GIF", + Some(ImageFormat::WebP) => "WEBP", + Some(ImageFormat::Bmp) => "BMP", + Some(ImageFormat::Ico) => "ICO", + Some(ImageFormat::Tiff) => "TIFF", + Some(_) => "IMG", + None => "IMG", + } +} + +/// Decode bounded bytes, then downsize for preview. Returns `None` for empty, +/// oversize, unsupported, or over-limit inputs. +pub(super) fn decode_bytes(bytes: &[u8]) -> Option { + if bytes.is_empty() || bytes.len() > MAX_COMPRESSED_BYTES { + return None; + } + let cursor = std::io::Cursor::new(bytes); + let mut reader = image::ImageReader::new(cursor).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_IMAGE_DIMENSION); + limits.max_image_height = Some(MAX_IMAGE_DIMENSION); + limits.max_alloc = Some(MAX_ALLOC_BYTES); + reader.limits(limits); + let format = reader.format(); + let decoded = reader.decode().ok()?; + let (ow, oh) = (decoded.width(), decoded.height()); + if ow == 0 || oh == 0 || ow > MAX_IMAGE_DIMENSION || oh > MAX_IMAGE_DIMENSION { + return None; + } + let image = if ow > PREVIEW_MAX_WIDTH || oh > PREVIEW_MAX_HEIGHT { + decoded.thumbnail(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT) + } else { + decoded + }; + Some(DecodedImage { + image, + format, + orig_width: ow, + orig_height: oh, + }) +} + +pub(super) enum RevisionRead { + Absent, + Present(Vec), + Failed, +} + +fn git_env_no_interactive(cmd: &mut std::process::Command) { + cmd.env("GIT_OPTIONAL_LOCKS", "0"); + cmd.env("GIT_TERMINAL_PROMPT", "0"); + cmd.env("SSH_ASKPASS_REQUIRE", "never"); + if std::env::var_os("GIT_SSH_COMMAND").is_none() { + cmd.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes"); + } +} + +fn git_blob_exists(cwd: &Path, spec: &str) -> Option { + use std::process::Stdio; + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(cwd) + .args(["cat-file", "-e", spec]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + git_env_no_interactive(&mut cmd); + match cmd.status() { + Ok(s) => Some(s.success()), + Err(_) => None, + } +} + +/// Bounded `git cat-file -p` read. Kills the child when the cap is exceeded +/// so a large blob cannot deadlock on a full pipe. +pub(super) fn read_revision_blob(cwd: &Path, revision: &str, path: &str) -> RevisionRead { + use std::process::Stdio; + if path.is_empty() { + return RevisionRead::Failed; + } + if revision.contains('\0') || path.contains('\0') { + return RevisionRead::Failed; + } + let spec = format!("{revision}:{path}"); + match git_blob_exists(cwd, &spec) { + Some(false) => return RevisionRead::Absent, + None => return RevisionRead::Failed, + Some(true) => {} + } + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(cwd) + .args(["cat-file", "-p", &spec]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .stdout(Stdio::piped()); + git_env_no_interactive(&mut cmd); + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(_) => return RevisionRead::Failed, + }; + let stdout = match child.stdout.take() { + Some(s) => s, + None => { + let _ = child.kill(); + let _ = child.wait(); + return RevisionRead::Failed; + } + }; + let mut limited = stdout.take((MAX_COMPRESSED_BYTES as u64) + 1); + let mut buf = Vec::with_capacity(8192.min(MAX_COMPRESSED_BYTES)); + if limited.read_to_end(&mut buf).is_err() { + let _ = child.kill(); + let _ = child.wait(); + return RevisionRead::Failed; + } + if buf.len() > MAX_COMPRESSED_BYTES { + let _ = child.kill(); + let _ = child.wait(); + return RevisionRead::Failed; + } + match child.wait() { + Ok(s) if s.success() => RevisionRead::Present(buf), + _ => { + if buf.is_empty() { + RevisionRead::Absent + } else { + RevisionRead::Failed + } + } + } +} + +/// Bounded worktree read. `Ok(None)` = legitimately missing; `Err(())` = +/// present but unreadable/oversize. +pub(super) fn read_worktree_bytes(repo_path: &Path, rel: &str) -> Result>, ()> { + if rel.is_empty() || rel.contains('\0') { + return Err(()); + } + let full = repo_path.join(rel); + let file = match std::fs::File::open(&full) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(()), + }; + if file.metadata().is_ok_and(|m| m.is_dir()) { + return Ok(None); + } + let mut limited = file.take((MAX_COMPRESSED_BYTES as u64) + 1); + let mut buf = Vec::new(); + if limited.read_to_end(&mut buf).is_err() { + return Err(()); + } + if buf.len() > MAX_COMPRESSED_BYTES { + return Err(()); + } + Ok(Some(buf)) +} + +pub(super) fn load_one_side(repo: &Path, src: ImageSource<'_>) -> Option> { + match src { + ImageSource::Missing => Some(None), + ImageSource::Worktree(rel) => match read_worktree_bytes(repo, rel) { + Ok(None) => Some(None), + Ok(Some(bytes)) => { + if bytes.is_empty() { + return None; + } + Some(Some(decode_bytes(&bytes)?)) + } + Err(()) => None, + }, + ImageSource::Revision { revision, path } => { + match read_revision_blob(repo, revision, path) { + RevisionRead::Absent => Some(None), + RevisionRead::Present(bytes) => { + if bytes.is_empty() { + return None; + } + Some(Some(decode_bytes(&bytes)?)) + } + RevisionRead::Failed => None, + } + } + } +} diff --git a/src/pager/mod.rs b/src/pager/mod.rs index a3b5330..fe1f978 100644 --- a/src/pager/mod.rs +++ b/src/pager/mod.rs @@ -1,5 +1,6 @@ pub mod diff_algo; pub mod highlight; +pub mod image_preview; pub mod side_by_side; pub mod word_diff; @@ -20,6 +21,8 @@ pub struct DiffLine { pub new_segments: Option>, /// If set, this line is a file header separator (multi-file diffs). pub file_header: Option, + /// Non-source placeholder row. Space-only labels provide vertical padding without source content. + pub preview_placeholder: Option<&'static str>, /// Index of the file section this line belongs to (for multi-file highlighting). pub section_index: usize, } diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index d9952a6..982e716 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -4,6 +4,7 @@ use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; +use std::collections::HashMap; use crate::config::Theme; @@ -19,6 +20,8 @@ pub struct FileSection { /// Pre-parsed diff data that can be sent across threads. /// Contains all the expensive-to-compute results (diff algorithm, tree-sitter highlighting). pub struct ParsedDiff { + pub image_preview: Option, + pub inline_images: HashMap, pub filename: String, pub old_content: String, pub new_content: String, @@ -39,6 +42,86 @@ pub enum DiffPanel { New, // Right panel (added / new code) } +/// Inline-image row expansion for multi-file diffs. +/// +/// Each key in `images` is a file-section index whose content is replaced by +/// `INLINE_IMAGE_ROWS` placeholder rows. Headers are preserved; text hunks in +/// later sections are remapped via an old-index -> new-index table. +impl ParsedDiff { + pub fn attach_inline_images( + &mut self, + images: HashMap, + ) { + if images.is_empty() { + return; + } + let sections: std::collections::HashSet = images.keys().copied().collect(); + self.expand_lines_for_image_sections(§ions); + self.inline_images = images; + } + + /// Expand placeholder rows for the given file-section indices. + /// + /// Testable without constructing image values: callers pass section ids, + /// this replaces each section's non-header content with + /// [`inline_image_placeholder_rows`] and remaps text hunks. + fn expand_lines_for_image_sections(&mut self, sections: &std::collections::HashSet) { + if sections.is_empty() { + return; + } + let old_lines = std::mem::take(&mut self.lines); + let mut old_to_new = vec![None; old_lines.len()]; + let mut expanded = std::collections::HashSet::new(); + for (old_index, line) in old_lines.iter().enumerate() { + if line.file_header.is_some() { + old_to_new[old_index] = Some(self.lines.len()); + self.lines.push(line.clone()); + // Headers exist even for rename-only sections with no body. + if sections.contains(&line.section_index) && expanded.insert(line.section_index) { + self.lines + .extend(inline_image_placeholder_rows(line.section_index)); + } + } else if sections.contains(&line.section_index) { + if expanded.insert(line.section_index) { + self.lines + .extend(inline_image_placeholder_rows(line.section_index)); + } + } else { + old_to_new[old_index] = Some(self.lines.len()); + self.lines.push(line.clone()); + } + } + // A single-file pure rename may have no source rows or header at all. + if old_lines.is_empty() && sections.contains(&0) { + self.lines.extend(inline_image_placeholder_rows(0)); + } + let had_staged = !self.hunk_staged.is_empty(); + let mut staged = Vec::new(); + self.hunk_starts = self + .hunk_starts + .iter() + .enumerate() + .filter_map(|(index, old_start)| { + let new_start = old_to_new.get(*old_start).copied().flatten()?; + if had_staged { + staged.push(self.hunk_staged.get(index).copied().unwrap_or(false)); + } + Some(new_start) + }) + .collect(); + if had_staged { + self.hunk_staged = staged; + } + self.hunk_line_offsets = self + .hunk_line_offsets + .iter() + .filter_map(|(index, old, new)| { + Some((old_to_new.get(*index).copied().flatten()?, *old, *new)) + }) + .collect(); + } +} + /// Mouse text selection state in the diff view. #[derive(Clone, Debug)] pub struct TextSelection { @@ -297,6 +380,10 @@ pub const REVERT_UNDO_STACK_CAP: usize = 20; /// State for the diff view panel. pub struct DiffViewState { + pub image_preview: Option, + pub inline_images: HashMap, + /// Graphics are suppressed while an overlay covers the diff pane. + pub image_preview_hidden: bool, pub scroll_offset: usize, pub horizontal_scroll: usize, pub lines: Vec, @@ -356,6 +443,9 @@ pub struct DiffViewState { impl Default for DiffViewState { fn default() -> Self { Self { + image_preview: None, + inline_images: HashMap::new(), + image_preview_hidden: false, scroll_offset: 0, horizontal_scroll: 0, lines: Vec::new(), @@ -456,6 +546,127 @@ impl DiffViewState { &self.filename } + /// True when this diff contains per-file separator lines (multi-file diff). + pub fn has_file_headers(&self) -> bool { + self.lines.iter().any(|l| l.file_header.is_some()) + } + + fn header_idx_at_or_before(&self, line_idx: usize) -> Option { + if self.lines.is_empty() { + return None; + } + let clamped = line_idx.min(self.lines.len().saturating_sub(1)); + for i in (0..=clamped).rev() { + if self + .lines + .get(i) + .and_then(|l| l.file_header.as_ref()) + .is_some() + { + return Some(i); + } + } + None + } + + /// Sticky header for a given DiffLine index. + /// Returns `(header_line_idx, header_text)` when `line_idx` is content + /// scrolled past its file header (so the header is no longer visible). + /// Returns `None` when the top row already is a header or when this + /// is a single-file diff with no separators. + pub fn sticky_header_for_line(&self, line_idx: usize) -> Option<(usize, &str)> { + if !self.has_file_headers() { + return None; + } + let line = self.lines.get(line_idx)?; + if line.file_header.is_some() { + return None; + } + let h_idx = self.header_idx_at_or_before(line_idx)?; + let header = self.lines.get(h_idx)?.file_header.as_ref()?; + Some((h_idx, header.as_str())) + } + + fn sticky_for_unified_with_width(&self, content_width: usize) -> Option<(usize, &str)> { + let w = content_width.max(1); + let (line_idx, _, _) = self.unified_line_at_visual_row(self.scroll_offset, w)?; + self.sticky_header_for_line(line_idx) + } + + /// Header text to pin to the first row of the viewport, if any. + /// Split layouts use `scroll_offset` directly; unified maps the visual + /// scroll row back to a DiffLine first. + pub fn sticky_file_header(&self) -> Option<&str> { + if self.view_layout == DiffViewLayout::Unified { + let w = self.last_content_width.max(1); + self.sticky_for_unified_with_width(w).map(|(_, h)| h) + } else { + self.sticky_header_for_line(self.scroll_offset) + .map(|(_, h)| h) + } + } + + /// DiffLine index of the header currently pinned by [`Self::sticky_file_header`]. + pub fn sticky_header_line_idx(&self) -> Option { + if self.view_layout == DiffViewLayout::Unified { + let w = self.last_content_width.max(1); + self.sticky_for_unified_with_width(w).map(|(idx, _)| idx) + } else { + self.sticky_header_for_line(self.scroll_offset) + .map(|(idx, _)| idx) + } + } + + /// True when `row` is the pinned sticky header row for this layout. + /// Used to skip highlight/selection work on the pinned row and to + /// adjust row -> line mapping for the reserved top row. + pub fn is_sticky_row(&self, row: u16, layout: &DiffPanelLayout) -> bool { + if row != layout.inner_y { + return false; + } + if self.view_layout == DiffViewLayout::Unified { + let content_width = layout + .new_content_end_x + .saturating_sub(layout.new_content_x) as usize; + self.sticky_for_unified_with_width(content_width.max(1)) + .is_some() + } else { + self.sticky_header_for_line(self.scroll_offset).is_some() + } + } + + /// Best-effort DiffLine index for a terminal row when hit-testing fails + /// (past end of diff). Mirrors [`Self::line_chunk_at_row`] including the + /// reserved sticky row so callers don't drift by one. + pub fn fallback_line_idx_for_row(&self, row: u16, layout: &DiffPanelLayout) -> usize { + if row < layout.inner_y { + return 0; + } + let raw_off = (row - layout.inner_y) as usize; + if self.view_layout == DiffViewLayout::Unified { + let content_width = layout + .new_content_end_x + .saturating_sub(layout.new_content_x) as usize; + let sticky = self + .sticky_for_unified_with_width(content_width.max(1)) + .is_some(); + let adj = raw_off.saturating_sub(if sticky { 1 } else { 0 }); + // Map the adjusted visual offset precisely instead of guessing. + if let Some((line_idx, _, _)) = + self.unified_line_chunk_panel_from(self.scroll_offset, adj, content_width.max(1)) + { + return line_idx; + } + return self.lines.len(); + } + let sticky = self.sticky_header_for_line(self.scroll_offset).is_some(); + let adj = raw_off.saturating_sub(if sticky { 1 } else { 0 }); + if sticky && raw_off == 0 { + return self.sticky_header_line_idx().unwrap_or(self.scroll_offset); + } + self.scroll_offset.saturating_add(adj) + } + /// Activate search mode with an empty query. pub fn start_search(&mut self) { self.search_active = true; @@ -577,6 +788,8 @@ impl DiffViewState { new_highlighter: FileHighlighter::new(new, filename), }]; ParsedDiff { + image_preview: None, + inline_images: HashMap::new(), filename: filename.to_string(), old_content: old.to_string(), new_content: new.to_string(), @@ -737,6 +950,8 @@ impl DiffViewState { new_highlighter: FileHighlighter::new(&new, actual_name), }]; ParsedDiff { + image_preview: None, + inline_images: HashMap::new(), filename: actual_name.to_string(), old_content: old, new_content: new, @@ -765,6 +980,7 @@ impl DiffViewState { old_segments: None, new_segments: None, file_header: Some((*file_name).clone()), + preview_placeholder: None, section_index: section_idx, }); @@ -788,6 +1004,8 @@ impl DiffViewState { let hunk_starts = super::diff_algo::find_hunk_starts(&lines); ParsedDiff { + image_preview: None, + inline_images: HashMap::new(), filename: new_filename, old_content: String::new(), new_content: String::new(), @@ -803,6 +1021,8 @@ impl DiffViewState { /// Apply a pre-parsed diff result, preserving scroll position for same-file reloads. pub fn apply_parsed(&mut self, parsed: ParsedDiff) { + self.image_preview = parsed.image_preview; + self.inline_images = parsed.inline_images; let same_file = self.filename == parsed.filename; let prev_selected_revert_hunk = self.selected_revert_hunk; let prev_hovered_revert_hunk = self.hovered_revert_hunk; @@ -838,6 +1058,8 @@ impl DiffViewState { /// Load a diff from old/new content (single file). pub fn load(&mut self, filename: &str, old: &str, new: &str) { + self.image_preview = None; + self.inline_images.clear(); // Preserve scroll position when reloading the same file (e.g. periodic refresh) let same_file = self.filename == filename; let prev_selected_revert_hunk = self.selected_revert_hunk; @@ -880,6 +1102,8 @@ impl DiffViewState { /// Load from raw diff output (git diff). /// Automatically detects multi-file diffs and splits into per-file sections. pub fn load_from_diff_output(&mut self, filename: &str, diff_output: &str) { + self.image_preview = None; + self.inline_images.clear(); let file_diffs = parse_multi_file_diff(diff_output); if file_diffs.len() <= 1 { @@ -953,6 +1177,7 @@ impl DiffViewState { old_segments: None, new_segments: None, file_header: Some((*file_name).clone()), + preview_placeholder: None, section_index: section_idx, }); @@ -1278,17 +1503,64 @@ impl DiffViewState { if row < layout.inner_y || row >= layout.inner_end_y { return None; } - let target_off = (row - layout.inner_y) as usize; if self.view_layout == DiffViewLayout::Unified { let content_width = layout .new_content_end_x .saturating_sub(layout.new_content_x) as usize; + if let Some((h_idx, _)) = self.sticky_for_unified_with_width(content_width.max(1)) { + if row == layout.inner_y { + return Some((h_idx, 0)); + } + let target_off = (row - layout.inner_y - 1) as usize; + return self + .unified_line_chunk_panel_at_offset(target_off, content_width) + .map(|(line_idx, chunk_idx, _)| (line_idx, chunk_idx)); + } + let target_off = (row - layout.inner_y) as usize; return self .unified_line_chunk_panel_at_offset(target_off, content_width) .map(|(line_idx, chunk_idx, _)| (line_idx, chunk_idx)); } + // Split layouts: the first row may be a pinned sticky header. + if let Some((h_idx, _)) = self.sticky_header_for_line(self.scroll_offset) { + if row == layout.inner_y { + return Some((h_idx, 0)); + } + let target_off = (row - layout.inner_y - 1) as usize; + if !self.wrap { + let idx = self.scroll_offset + target_off; + return if idx < self.lines.len() { + Some((idx, 0)) + } else { + None + }; + } + let panel_width = layout + .old_content_end_x + .saturating_sub(layout.old_content_x) as usize; + let right_content_width = layout + .new_content_end_x + .saturating_sub(layout.new_content_x) + as usize; + let mut acc = 0usize; + // Guard when scroll_offset is past the end (e.g. transient reload). + if self.scroll_offset >= self.lines.len() { + return None; + } + for (offset, diff_line) in self.lines[self.scroll_offset..].iter().enumerate() { + let line_idx = self.scroll_offset + offset; + let num_rows = line_visual_height(diff_line, panel_width, right_content_width); + if target_off < acc + num_rows { + return Some((line_idx, target_off - acc)); + } + acc += num_rows; + } + return None; + } + + let target_off = (row - layout.inner_y) as usize; if !self.wrap { let idx = self.scroll_offset + target_off; return if idx < self.lines.len() { @@ -1306,6 +1578,9 @@ impl DiffViewState { .saturating_sub(layout.new_content_x) as usize; let mut acc = 0usize; + if self.scroll_offset >= self.lines.len() { + return None; + } for (offset, diff_line) in self.lines[self.scroll_offset..].iter().enumerate() { let line_idx = self.scroll_offset + offset; let num_rows = line_visual_height(diff_line, panel_width, right_content_width); @@ -1326,15 +1601,29 @@ impl DiffViewState { layout: &DiffPanelLayout, fallback_panel: DiffPanel, ) -> Option<(usize, usize, DiffPanel)> { - let (line_idx, chunk_idx) = self.line_chunk_at_row(row, layout)?; - if self.view_layout != DiffViewLayout::Unified { - return Some((line_idx, chunk_idx, fallback_panel)); + if row < layout.inner_y || row >= layout.inner_end_y { + return None; } - - let content_width = layout - .new_content_end_x - .saturating_sub(layout.new_content_x) as usize; - self.unified_line_chunk_panel_at_offset((row - layout.inner_y) as usize, content_width) + if self.view_layout == DiffViewLayout::Unified { + let content_width = layout + .new_content_end_x + .saturating_sub(layout.new_content_x) as usize; + if self + .sticky_for_unified_with_width(content_width.max(1)) + .is_some() + { + if row == layout.inner_y { + let (h_idx, _) = self.sticky_for_unified_with_width(content_width.max(1))?; + return Some((h_idx, 0, fallback_panel)); + } + let adj = (row - layout.inner_y - 1) as usize; + return self.unified_line_chunk_panel_at_offset(adj, content_width); + } + let off = (row - layout.inner_y) as usize; + return self.unified_line_chunk_panel_at_offset(off, content_width); + } + let (line_idx, chunk_idx) = self.line_chunk_at_row(row, layout)?; + Some((line_idx, chunk_idx, fallback_panel)) } fn unified_line_chunk_panel_at_offset( @@ -1622,6 +1911,34 @@ pub fn render_diff( let inner = block.inner(area); frame.render_widget(block, area); + if !state.image_preview_hidden { + if let Some(preview) = &mut state.image_preview { + super::image_preview::render(frame, inner, preview, state.side_view, theme); + return; + } + } + + // Only a lone unavailable file fills the viewport. In multi-file buffers, + // the five placeholder rows below remain ordinary scrollable rows. + // Inline images use compact rows even when lone; never take the fallback. + if state.inline_images.is_empty() + && state + .lines + .iter() + .all(|line| line.preview_placeholder.is_some()) + { + let message = state + .lines + .iter() + .find_map(|line| { + line.preview_placeholder + .filter(|message| !message.trim().is_empty()) + }) + .unwrap_or(""); + render_preview_placeholder(frame.buffer_mut(), inner, message, theme); + return; + } + if inner.width < 10 || inner.height < 2 { return; } @@ -1640,31 +1957,42 @@ pub fn render_diff( }; let visible_height = inner.height as usize; - let buf = frame.buffer_mut(); - if state.view_layout == DiffViewLayout::Unified { - render_unified_diff_body( - buf, - inner, - state, - theme, - visible_height, - show_revert_markers, - ); + { + let buf = frame.buffer_mut(); + render_unified_diff_body( + buf, + inner, + state, + theme, + visible_height, + show_revert_markers, + ); + } + render_inline_image_regions(frame, area, inner, state, theme); return; } + let buf = frame.buffer_mut(); if single_side.is_some() || is_new_file { // Single-panel mode: new file, old-only, or new-only let show_panel = single_side.unwrap_or(DiffPanel::New); // new-file defaults to New let content_width = inner.width.saturating_sub(gutter_width); + // Sticky file header: pin the current file's separator to the first + // row when scrolled past it in a multi-file diff. + let sticky = state.sticky_file_header().map(|s| s.to_string()); let mut row = 0usize; - for (idx_offset, diff_line) in state.lines[state.scroll_offset..].iter().enumerate() { + if let Some(ref header) = sticky { + render_file_header(buf, inner.x, inner.y, inner.width, header, theme); + row = 1; + } + let start = state.scroll_offset.min(state.lines.len()); + for (idx_offset, diff_line) in state.lines[start..].iter().enumerate() { if row >= visible_height { break; } - let line_idx = state.scroll_offset + idx_offset; + let line_idx = start + idx_offset; // Handle file header separator lines if let Some(ref header) = diff_line.file_header { @@ -1674,6 +2002,22 @@ pub fn render_diff( continue; } + if let Some(message) = diff_line.preview_placeholder { + // Inline images overlay these rows; skip stripes when visible. + let has_inline = !state.image_preview_hidden + && state.inline_images.contains_key(&diff_line.section_index); + if !has_inline { + render_preview_placeholder( + buf, + Rect::new(inner.x, inner.y + row as u16, inner.width, 1), + message, + theme, + ); + } + row += 1; + continue; + } + let default_hl = FileHighlighter::default(); let (old_highlighter, new_highlighter) = state .highlighters_for_section(diff_line.section_index) @@ -1823,12 +2167,20 @@ pub fn render_diff( let mut hover_tooltip_y: Option = None; + // Sticky file header: pin the current file's separator to the first + // row when scrolled past it in a multi-file diff. + let sticky = state.sticky_file_header().map(|s| s.to_string()); let mut row = 0usize; - for (idx_offset, diff_line) in state.lines[state.scroll_offset..].iter().enumerate() { + if let Some(ref header) = sticky { + render_file_header(buf, inner.x, inner.y, inner.width, header, theme); + row = 1; + } + let start = state.scroll_offset.min(state.lines.len()); + for (idx_offset, diff_line) in state.lines[start..].iter().enumerate() { if row >= visible_height { break; } - let line_idx = state.scroll_offset + idx_offset; + let line_idx = start + idx_offset; // Handle file header separator lines if let Some(ref header) = diff_line.file_header { @@ -1843,6 +2195,22 @@ pub fn render_diff( .highlighters_for_section(diff_line.section_index) .unwrap_or((&default_hl, &default_hl)); + if let Some(message) = diff_line.preview_placeholder { + // Inline images overlay these rows; skip stripes when visible. + let has_inline = !state.image_preview_hidden + && state.inline_images.contains_key(&diff_line.section_index); + if !has_inline { + render_preview_placeholder( + buf, + Rect::new(inner.x, inner.y + row as u16, inner.width, 1), + message, + theme, + ); + } + row += 1; + continue; + } + let staged = state.is_staged_line(line_idx); let (left_bg, right_bg) = line_bg_colors(diff_line.change_type, theme, staged); let (left_gutter_bg, right_gutter_bg) = @@ -2161,6 +2529,75 @@ pub fn render_diff( ); } } + render_inline_image_regions(frame, area, inner, state, theme); +} + +/// Overlay inline images onto their placeholder rows (viewport only). +/// +/// Gathers contiguous visible placeholder rows per file section via +/// `line_chunk_at_row` (which already accounts for wrap, sticky headers and +/// layout), then draws each section's image into its visible region. Text and +/// sticky header rows never join a region; partially scrolled sections use +/// only the visible sub-region so following content is never overwritten. +fn render_inline_image_regions( + frame: &mut Frame, + area: Rect, + inner: Rect, + state: &mut DiffViewState, + theme: &Theme, +) { + if state.image_preview_hidden || state.inline_images.is_empty() { + return; + } + if inner.width == 0 || inner.height == 0 { + return; + } + let layout = DiffPanelLayout::compute(area, state); + // Group adjacent visible image rows; headers and wrapped text stay outside + // these rectangles, so graphics cannot overwrite their source content. + let mut runs: Vec<(usize, u16, u16)> = Vec::new(); + for y in inner.y..inner.bottom() { + if state.is_sticky_row(y, &layout) { + continue; + } + let Some((line_index, _)) = state.line_chunk_at_row(y, &layout) else { + continue; + }; + let Some(line) = state.lines.get(line_index) else { + continue; + }; + if line.file_header.is_some() + || line.preview_placeholder.is_none() + || !state.inline_images.contains_key(&line.section_index) + { + continue; + } + if let Some((section, _, end)) = runs.last_mut() { + if *section == line.section_index && *end == y { + *end = y + 1; + continue; + } + } + runs.push((line.section_index, y, y + 1)); + } + let visible: std::collections::HashSet<_> = + runs.iter().map(|(section, _, _)| *section).collect(); + for (section, preview) in &mut state.inline_images { + if !visible.contains(section) { + preview.unload(); + } + } + let side = state.side_view; + for (sec, start_y, end_y) in runs { + let h = end_y.saturating_sub(start_y); + if h == 0 { + continue; + } + let rect = Rect::new(inner.x, start_y, inner.width, h); + if let Some(preview) = state.inline_images.get_mut(&sec) { + preview.render(frame, rect, side, theme); + } + } } fn render_unified_diff_body( @@ -2184,7 +2621,16 @@ fn render_unified_diff_body( // (all deletes in a change block, then all inserts) so mid-block scrolling // does not drop earlier paired lines. let mut skip = state.scroll_offset; + // Sticky file header: pin the current file's separator to the first row + // when scrolled past it in a multi-file diff. + let sticky = state + .sticky_for_unified_with_width(content_width as usize) + .map(|(_, h)| h.to_string()); let mut row = 0usize; + if let Some(ref header) = sticky { + render_file_header(buf, inner.x, inner.y, inner.width, header, theme); + row = 1; + } let mut line_idx = 0usize; while line_idx < state.lines.len() { if row >= visible_height { @@ -2205,6 +2651,27 @@ fn render_unified_diff_body( continue; } + if let Some(message) = diff_line.preview_placeholder { + if skip > 0 { + skip -= 1; + } else { + // Inline images overlay these rows; skip stripes when visible. + let has_inline = !state.image_preview_hidden + && state.inline_images.contains_key(&diff_line.section_index); + if !has_inline { + render_preview_placeholder( + buf, + Rect::new(inner.x, inner.y + row as u16, inner.width, 1), + message, + theme, + ); + } + row += 1; + } + line_idx += 1; + continue; + } + if diff_line.change_type == ChangeType::Equal { let height = unified_line_visual_height(diff_line, content_width as usize, state); if skip >= height { @@ -2564,6 +3031,48 @@ fn render_revert_tooltip( } /// Render a file header separator line spanning the full width. +/// The same diagonal glyph and muted foreground used for absent diff sides. +fn render_preview_placeholder(buf: &mut Buffer, area: Rect, message: &str, theme: &Theme) { + let area = area.intersection(buf.area); + if area.is_empty() { + return; + } + let hatch = "╱".repeat(area.width as usize); + let hatch_style = Style::reset().fg(theme.diff_line_number); + for y in area.y..area.bottom() { + buf.set_stringn(area.x, y, &hatch, area.width as usize, hatch_style); + } + if message.is_empty() { + return; + } + // Keep the reason on one row even in narrow terminals; never wrap it into + // the next file's header. ASCII labels make clipping cell-width safe. + let label = if area.width as usize >= message.len() + 4 { + format!(" {message} ") + } else { + message.chars().take(area.width as usize).collect() + }; + let x = area.x + (area.width - label.len() as u16) / 2; + let y = area.y + area.height / 2; + let padding = " ".repeat(label.len()); + for padding_y in y.saturating_sub(1).max(area.y)..=(y + 1).min(area.bottom() - 1) { + buf.set_stringn( + x, + padding_y, + &padding, + label.len(), + Style::reset().fg(theme.text_dimmed), + ); + } + buf.set_stringn( + x, + y, + label, + area.width as usize, + Style::reset().fg(theme.text_dimmed), + ); +} + fn render_file_header(buf: &mut Buffer, x: u16, y: u16, width: u16, filename: &str, theme: &Theme) { let buf_area = buf.area(); if y < buf_area.y || y >= buf_area.y + buf_area.height { @@ -3066,10 +3575,14 @@ pub fn render_diff_search_highlights( if y >= pl.inner_end_y || y >= buf_area.y + buf_area.height { break; } + // Never highlight the pinned sticky header row. + if state.is_sticky_row(y, &pl) { + continue; + } let line_idx = state .line_chunk_at_row(y, &pl) .map(|(line_idx, _)| line_idx) - .unwrap_or(state.scroll_offset + row_offset); + .unwrap_or_else(|| state.fallback_line_idx_for_row(y, &pl)); let is_current_line = current_match_line == Some(line_idx); @@ -3199,7 +3712,7 @@ pub fn render_diff_search_bar(frame: &mut Frame, area: Rect, state: &DiffViewSta /// Parse a multi-file unified diff into per-file sections. /// Returns Vec of (filename, raw_diff_slice) without re-joining bodies. -fn parse_multi_file_diff(diff: &str) -> Vec<(String, &str)> { +pub(crate) fn parse_multi_file_diff(diff: &str) -> Vec<(String, &str)> { let bytes = diff.as_bytes(); let mut sections: Vec<(String, &str)> = Vec::new(); let mut current_filename = String::new(); @@ -3223,12 +3736,9 @@ fn parse_multi_file_diff(diff: &str) -> Vec<(String, &str)> { )); } current_filename = extract_filename_from_diff_header(line); - let after = if line_end < bytes.len() { - line_end + 1 - } else { - line_end - }; - section_start = Some(after); + // Keep the header: binary sections have no ---/+++ paths, and + // delayed image loads need the exact old/new source identities. + section_start = Some(line_start); } if line_end >= bytes.len() { @@ -3305,15 +3815,8 @@ fn build_file_sections_parallel(section_meta: &[(&str, &str)]) -> Vec String { - // Format: "diff --git a/some/path b/some/path" - // We want "some/path" (the b/ side, which is the new name) - if let Some(b_part) = line.split(" b/").last() { - b_part.to_string() - } else { - // Fallback: strip "diff --git " prefix - line.trim_start_matches("diff --git ").to_string() - } +fn extract_filename_from_diff_header(header: &str) -> String { + crate::git::diff_paths::new_path_from_header(header).unwrap_or_default() } /// Parse a unified diff into old/new content for side-by-side display. @@ -3365,7 +3868,7 @@ fn parse_unified_diff(diff: &str) -> (String, String) { fn diff_lines_from_unified_or_rename_only(diff: &str, tab_width: usize) -> Vec { if is_binary_diff(diff) { - return binary_file_placeholder_lines(tab_width); + return binary_file_placeholder_lines(); } if let Some((old_path, new_path)) = rename_only_paths(diff) { return rename_only_lines(&old_path, &new_path, tab_width); @@ -3377,24 +3880,64 @@ fn diff_lines_from_unified_or_rename_only(diff: &str, tab_width: usize) -> Vec bool { diff.lines().any(|line| { - let t = line.trim_start(); - t.starts_with("Binary files ") - || t.starts_with("Binary file ") - || t.starts_with("GIT binary patch") + // Do not trim: a leading space marks ordinary hunk context, not metadata. + line.starts_with("Binary files ") + || line.starts_with("Binary file ") + || line.starts_with("GIT binary patch") }) } -fn binary_file_placeholder_lines(tab_width: usize) -> Vec { - let msg = super::expand_tabs("Binary file (not viewable)", tab_width); - vec![DiffLine { - old_line: Some((1, msg.clone())), - new_line: Some((1, msg)), +fn binary_file_placeholder_lines() -> Vec { + [ + "", + " ", + "Binary cannot be previewed", + " ", + "", + ] + .into_iter() + .map(|message| DiffLine { + old_line: None, + new_line: None, change_type: ChangeType::Equal, old_segments: None, new_segments: None, file_header: None, + preview_placeholder: Some(message), section_index: 0, - }] + }) + .collect() +} + +/// Placeholder rows backing one inline-image section. +/// +/// Uses the existing `DiffLine` placeholder model (no new field): all rows are +/// hatch stripes, the middle row carries the fallback label so hidden images +/// still read usefully. Length is `INLINE_IMAGE_ROWS`. +fn inline_image_placeholder_rows(section_index: usize) -> Vec { + let total_rows = super::image_preview::INLINE_IMAGE_ROWS; + let mid = total_rows / 2; + (0..total_rows) + .map(|r| { + let message: &'static str = if r == mid { + "Binary cannot be previewed" + } else if r + 1 == mid || r == mid + 1 { + " " + } else { + "" + }; + DiffLine { + old_line: None, + new_line: None, + change_type: ChangeType::Equal, + old_segments: None, + new_segments: None, + file_header: None, + preview_placeholder: Some(message), + section_index, + } + }) + .collect() } /// True when git emitted a rename/copy with no content hunks (pure move). @@ -3432,6 +3975,7 @@ fn rename_only_lines(old_path: &str, new_path: &str, tab_width: usize) -> Vec Buffer { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| { + render_diff( + frame, + Rect::new(0, 0, width, height), + state, + &Theme::dark(), + true, + false, + true, + ); + }) + .unwrap(); + terminal.backend().buffer().clone() + } + + fn buffer_row(buf: &Buffer, y: u16) -> String { + (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect() + } + + #[test] + fn lone_binary_fills_pane_in_every_layout() { + for layout in [DiffViewLayout::SideBySide, DiffViewLayout::Unified] { + for side in [ + DiffSideView::Both, + DiffSideView::OldOnly, + DiffSideView::NewOnly, + ] { + let mut state = DiffViewState::new(); + state.load_from_diff_output("foo.png", BINARY_DIFF); + state.view_layout = layout; + state.side_view = side; + let buf = render_preview(&mut state, 60, 12); + assert!(buffer_row(&buf, 6).contains(" Binary cannot be previewed ")); + for y in [1, 4, 8, 10] { + assert_eq!( + (1..59).map(|x| buf[(x, y)].symbol()).collect::(), + "╱".repeat(58) + ); + } + for y in [5, 7] { + assert_eq!( + buffer_row(&buf, y), + format!("│{}{}{}│", "╱".repeat(14), " ".repeat(30), "╱".repeat(14)) + ); + } + assert_eq!(buf[(1, 1)].fg, Theme::dark().diff_line_number); + assert!(state.file_line_number(1, DiffPanel::New).is_none()); + } + } + } + + #[test] + fn mixed_binary_placeholder_is_compact_in_every_layout() { + let diff = format!("{BINARY_DIFF}{TEXT_DIFF}{BINARY_DIFF}"); + for layout in [DiffViewLayout::SideBySide, DiffViewLayout::Unified] { + for side in [ + DiffSideView::Both, + DiffSideView::OldOnly, + DiffSideView::NewOnly, + ] { + for wrap in [false, true] { + for width in [24, 80] { + let mut state = DiffViewState::new(); + state.apply_parsed(DiffViewState::parse_diff_output( + "mixed", &diff, 4, true, + )); + state.view_layout = layout; + state.side_view = side; + state.wrap = wrap; + let buf = render_preview(&mut state, width, 18); + assert!(buffer_row(&buf, 1).contains("foo.png")); + assert!(buffer_row(&buf, 4).contains("Binary")); + for y in [2, 6] { + assert_eq!( + (1..width - 1) + .map(|x| buf[(x, y)].symbol()) + .collect::(), + "╱".repeat(width as usize - 2) + ); + } + for y in [3, 5] { + let row = buffer_row(&buf, y); + assert!(row.contains(&" ".repeat(30.min(width as usize - 2)))); + if width == 80 { + assert!(row.contains('╱')); + } + } + assert!(buffer_row(&buf, 7).contains("text.txt")); + assert!( + buffer_row(&buf, 8).contains(if side == DiffSideView::NewOnly { + "new" + } else { + "old" + }) + ); + // Exactly five rows per binary, not viewport-sized blocks. + assert_eq!( + state + .lines + .iter() + .filter(|line| line.preview_placeholder.is_some()) + .count(), + 10 + ); + assert_eq!(state.hunk_starts, vec![7]); + assert_eq!(state.file_line_number(7, DiffPanel::New), Some(1)); + let panel = DiffPanelLayout::compute(buf.area, &state); + assert_eq!(state.line_chunk_at_row(7, &panel), Some((6, 0))); + state.start_search(); + state.search_query = "Binary".to_string(); + state.update_search(); + assert!(state.search_matches.is_empty()); + } + } + } + } + } + + #[test] + fn scrolling_into_binary_keeps_sticky_header_and_next_file_aligned() { + for layout in [DiffViewLayout::SideBySide, DiffViewLayout::Unified] { + for wrap in [false, true] { + let mut state = DiffViewState::new(); + state.load_from_diff_output("mixed", &format!("{BINARY_DIFF}{TEXT_DIFF}")); + state.view_layout = layout; + state.wrap = wrap; + state.scroll_offset = 3; // Middle row of the binary placeholder. + let buf = render_preview(&mut state, 80, 10); + assert!(buffer_row(&buf, 1).contains("foo.png")); + assert!(buffer_row(&buf, 2).contains("Binary cannot be previewed")); + assert!(buffer_row(&buf, 3).contains('╱')); + assert_eq!( + (1..79).map(|x| buf[(x, 4)].symbol()).collect::(), + "╱".repeat(78) + ); + assert!(buffer_row(&buf, 5).contains("text.txt")); + let panel = DiffPanelLayout::compute(buf.area, &state); + assert_eq!(state.line_chunk_at_row(5, &panel), Some((6, 0))); + } + } + } + + #[test] + fn placeholder_clips_to_tiny_offset_areas() { + for width in 0..32 { + for height in 0..4 { + let mut buf = Buffer::empty(Rect::new(0, 0, 40, 8)); + let area = Rect::new(3, 2, width, height); + render_preview_placeholder( + &mut buf, + area, + "Binary cannot be previewed", + &Theme::dark(), + ); + for y in 0..8 { + for x in 0..40 { + if x < area.x || x >= area.right() || y < area.y || y >= area.bottom() { + assert_eq!(buf[(x, y)].symbol(), " "); + } + } + } + } + } + } + + #[test] + fn binary_patch_is_placeholder_but_binary_words_in_source_are_not() { + let parsed = DiffViewState::parse_diff_output( + "foo.png", + "diff --git a/foo.png b/foo.png\nGIT binary patch\nliteral 3\nabc\n", + 4, + true, + ); + assert_eq!( + parsed.lines[2].preview_placeholder, + Some("Binary cannot be previewed") + ); + let text = "diff --git a/text.txt b/text.txt\n--- a/text.txt\n+++ b/text.txt\n@@ -1,2 +1,2 @@\n Binary files are normal text here\n-old\n+new\n"; + let parsed = DiffViewState::parse_diff_output("text.txt", text, 4, true); + assert!( + parsed + .lines + .iter() + .all(|line| line.preview_placeholder.is_none()) + ); + assert!(!parsed.hunk_starts.is_empty()); + } + + #[test] + fn inline_rows_match_const_and_middle_label() { + assert_eq!(super::super::image_preview::INLINE_IMAGE_ROWS, 12); + let rows = super::inline_image_placeholder_rows(3); + assert_eq!(rows.len(), 12); + assert!(rows.iter().all(|l| l.file_header.is_none())); + assert!(rows.iter().all(|l| l.section_index == 3)); assert!( - text.contains("not viewable") || text.contains("Binary file"), - "got {text:?}" + rows.iter() + .all(|l| l.old_line.is_none() && l.new_line.is_none()) + ); + let mid = 12 / 2; + assert_eq!( + rows[mid].preview_placeholder, + Some("Binary cannot be previewed") ); + // Neighbours are blank padding, rest are empty hatch rows. + assert_eq!( + rows[mid - 1].preview_placeholder, + Some(" ") + ); + assert_eq!( + rows[mid + 1].preview_placeholder, + Some(" ") + ); + assert_eq!(rows[0].preview_placeholder, Some("")); + } + + #[test] + fn attach_empty_is_noop() { + let mut parsed = DiffViewState::parse_diff_output("foo.png", BINARY_DIFF, 4, true); + let before = parsed.lines.len(); + parsed.attach_inline_images(std::collections::HashMap::< + usize, + super::super::image_preview::InlineImagePreview, + >::new()); + assert_eq!(parsed.lines.len(), before); + assert!(parsed.inline_images.is_empty()); + } + + #[test] + fn image_expansion_replaces_binary_and_remaps_text_hunks() { + let diff = format!("{BINARY_DIFF}{TEXT_DIFF}"); + let mut parsed = DiffViewState::parse_diff_output("mixed", &diff, 4, true); + // Preconditions: 2 file headers + 5 binary rows + text hunk. + let headers: Vec = parsed + .lines + .iter() + .enumerate() + .filter_map(|(i, l)| l.file_header.as_ref().map(|_| i)) + .collect(); + assert_eq!(headers.len(), 2); + let binary_section = parsed.lines[headers[0] + 1].section_index; + let text_section = parsed.lines[headers[1] + 1].section_index; + assert_ne!(binary_section, text_section); + let binary_rows = parsed + .lines + .iter() + .filter(|l| l.preview_placeholder.is_some()) + .count(); + assert_eq!(binary_rows, 5); + let old_hunk = parsed.hunk_starts[0]; + let old_offsets = parsed.hunk_line_offsets.clone(); + let text_header = parsed.lines[headers[1]] + .file_header + .clone() + .expect("text file header"); + + let mut sections = std::collections::HashSet::new(); + sections.insert(binary_section); + parsed.expand_lines_for_image_sections(§ions); + + // Binary block grew 5 -> 12 (+7); header preserved. + assert!(parsed.lines[headers[0]].file_header.is_some()); + let new_binary_rows: Vec<_> = parsed + .lines + .iter() + .filter(|l| l.preview_placeholder.is_some() && l.section_index == binary_section) + .collect(); + assert_eq!(new_binary_rows.len(), 12); + assert_eq!( + new_binary_rows[6].preview_placeholder, + Some("Binary cannot be previewed") + ); + // Text hunks shifted by +7, offsets remapped, staged untouched. + assert_eq!(parsed.hunk_starts.len(), 1); + assert_eq!(parsed.hunk_starts[0], old_hunk + 7); + assert_eq!(parsed.hunk_line_offsets.len(), old_offsets.len()); + for ((new_idx, _, _), (old_idx, _, _)) in + parsed.hunk_line_offsets.iter().zip(old_offsets.iter()) + { + assert_eq!(*new_idx, *old_idx + 7); + } + assert!(parsed.hunk_staged.is_empty()); + // Text content still resolves to the same file. + let mut state = DiffViewState::new(); + state.apply_parsed(parsed); + assert_eq!( + state.file_line_number(state.hunk_starts[0], DiffPanel::New), + Some(1) + ); + assert_eq!(state.file_at_line(state.hunk_starts[0]), text_header); + } + + #[test] + fn rename_only_section_expands_to_image_rows() { + let diff = "diff --git a/old.png b/new.png\nsimilarity index 100%\nrename from old.png\nrename to new.png\n"; + let mut parsed = DiffViewState::parse_diff_output("old.png -> new.png", diff, 4, true); + assert_eq!(parsed.lines.len(), 1); + let mut sections = std::collections::HashSet::new(); + sections.insert(0); + parsed.expand_lines_for_image_sections(§ions); + assert_eq!(parsed.lines.len(), 12); + assert!(parsed.lines.iter().all(|l| l.preview_placeholder.is_some())); + assert_eq!( + parsed.lines[6].preview_placeholder, + Some("Binary cannot be previewed") + ); + assert!(parsed.hunk_starts.is_empty()); + assert!(parsed.hunk_line_offsets.is_empty()); + } + + #[test] + fn image_rows_map_via_line_chunk_and_sticky() { + let diff = format!("{BINARY_DIFF}{TEXT_DIFF}"); + let mut parsed = DiffViewState::parse_diff_output("mixed", &diff, 4, true); + let binary_section = parsed.lines[1].section_index; + let mut sections = std::collections::HashSet::new(); + sections.insert(binary_section); + parsed.expand_lines_for_image_sections(§ions); + let mut state = DiffViewState::new(); + state.apply_parsed(parsed); + for layout in [DiffViewLayout::SideBySide, DiffViewLayout::Unified] { + for wrap in [false, true] { + state.view_layout = layout; + state.wrap = wrap; + state.scroll_offset = 0; + let area = Rect::new(0, 0, 80, 18); + let panel = DiffPanelLayout::compute(area, &state); + // First content row after the header is an image placeholder. + // Header at row 1, image rows follow; sticky only when scrolled. + assert_eq!(state.line_chunk_at_row(1, &panel), Some((0, 0))); + // Image block rows map to placeholder lines in the image section. + let mapped = state.line_chunk_at_row(2, &panel).expect("image row maps"); + let line = &state.lines[mapped.0]; + assert!(line.preview_placeholder.is_some()); + assert_eq!(line.section_index, binary_section); + // Scroll into the middle of the image block: sticky pins header, + // next file stays aligned and text hunks still resolve. + state.scroll_offset = if layout == DiffViewLayout::Unified { + 3 + } else { + 3 + }; + let buf = render_preview(&mut state, 80, 10); + assert!(buffer_row(&buf, 1).contains("foo.png")); + // No text row is mistaken for an image row. + let panel2 = DiffPanelLayout::compute(buf.area, &state); + for y in panel2.inner_y..panel2.inner_end_y { + if state.is_sticky_row(y, &panel2) { + continue; + } + if let Some((idx, _)) = state.line_chunk_at_row(y, &panel2) { + let l = &state.lines[idx]; + if l.file_header.is_some() { + continue; + } + if l.preview_placeholder.is_none() { + assert_ne!(l.section_index, binary_section); + } + } + } + state.scroll_offset = 0; + } + } + } + + #[test] + fn inline_regions_noop_when_hidden_or_empty() { + let mut state = DiffViewState::new(); + state.load_from_diff_output("foo.png", BINARY_DIFF); + // Empty inline map: normal stripes still render, overlay is a noop. + let buf = render_preview(&mut state, 60, 12); + assert!(buffer_row(&buf, 6).contains("Binary cannot be previewed")); + // Hidden flag with empty map also renders stripes without panic. + state.image_preview_hidden = true; + let buf2 = render_preview(&mut state, 60, 12); + assert!(buffer_row(&buf2, 6).contains("Binary cannot be previewed")); + } + #[test] + fn split_binary_sections_keep_paths_for_lazy_image_loading() { + let diff = "diff --git a/assets/old name.png b/assets/new name.png\n\ + similarity index 90%\nrename from assets/old name.png\n\ + rename to assets/new name.png\n\ + Binary files a/assets/old name.png and b/assets/new name.png differ\n\ + diff --git a/text.txt b/text.txt\n--- a/text.txt\n+++ b/text.txt\n\ + @@ -1 +1 @@\n-old\n+new\n"; + let sections = parse_multi_file_diff(diff); + assert_eq!(sections.len(), 2); + assert!(sections[0].1.starts_with("diff --git ")); + let paths = crate::git::diff_paths::paths_from_diff(sections[0].1).unwrap(); + assert_eq!(paths.old.as_deref(), Some("assets/old name.png")); + assert_eq!(paths.new.as_deref(), Some("assets/new name.png")); + assert_eq!(sections[1].0, "text.txt"); } }