From 09fc1bbbec25ffd723f0272a453abcd874c1a7ff Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 1 Oct 2026 18:39:23 +0800 Subject: [PATCH 1/3] feat(pager): add inline image diff previews for raster files - Render Before/After previews for PNG, JPEG, GIF, WebP, BMP, ICO, and TIFF in Kitty and Ghostty, with Added/Deleted full-width states backed by worktree, index, and revision blobs - Add background decode with memory/dimension limits, 20 MiB cap, and striped binary fallback for unsupported terminals, oversized files, and corrupt images - Support capped inline image sections in folder diffs across split/unified and wrapped views with viewport-driven loading and cache-aware unloading - Add diff path parsing for quoted/renamed headers, terminal graphics protocol handling, `LAZYGITRS_IMAGE_PREVIEW` opt-out/experimental flags, and tmux/screen/Zellij fallback --- Cargo.lock | 385 ++++ Cargo.toml | 2 + README.md | 24 + src/git/diff.rs | 2 +- src/git/diff_paths.rs | 491 +++++ src/git/mod.rs | 1 + src/gui/controller/diff_mode.rs | 33 +- src/gui/input.rs | 69 +- src/gui/mod.rs | 222 ++- src/pager/image_preview.rs | 3116 +++++++++++++++++++++++++++++++ src/pager/mod.rs | 1 + src/pager/side_by_side.rs | 521 +++++- 12 files changed, 4802 insertions(+), 65 deletions(-) create mode 100644 src/git/diff_paths.rs create mode 100644 src/pager/image_preview.rs diff --git a/Cargo.lock b/Cargo.lock index bfc8b4c..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,10 +678,12 @@ dependencies = [ "clap", "crossterm", "dirs", + "image", "include_dir", "libc", "once_cell", "ratatui", + "ratatui-image", "serde", "serde_json", "serde_yaml", @@ -642,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" @@ -654,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" @@ -663,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" @@ -716,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" @@ -727,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" @@ -736,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" @@ -745,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" @@ -766,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" @@ -948,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" @@ -1128,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" @@ -1455,6 +1729,12 @@ 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" @@ -1477,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" @@ -1594,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" @@ -1648,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 8118aea..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" diff --git a/README.md b/README.md index 6603148..adfe4b1 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,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/git/diff.rs b/src/git/diff.rs index 18928b1..09b848d 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -510,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 8a131cd..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; diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index 3e1671d..91941c4 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -902,6 +902,24 @@ pub fn maybe_request_diff(gui: &mut Gui, generation: u64, diff_key: String) { 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( @@ -955,12 +973,17 @@ pub fn maybe_request_diff(gui: &mut Gui, generation: u64, diff_key: String) { 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); + super::super::attach_inline_image_previews( + &mut parsed, + &git, &combined_diff, - 4, - true, - )) + &ref_a, + Some(&ref_b), + &std::collections::HashSet::new(), + ); + DiffPayload::Parsed(parsed) } }); } else { diff --git a/src/gui/input.rs b/src/gui/input.rs index d8fc62b..6a8425d 100644 --- a/src/gui/input.rs +++ b/src/gui/input.rs @@ -170,13 +170,7 @@ 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). @@ -189,9 +183,23 @@ fn next_events(source: &mut impl EventSource) -> io::Result> { 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. Without this guard, a Kitty reply's G opens Reset and its + // remaining `i=31;OK` payload gets typed into the dialog. + if key.kind == KeyEventKind::Press + && key.modifiers == KeyModifiers::ALT + && matches!(key.code, KeyCode::Char(']' | 'P' | '^' | '_')) + { + consume_control_string(source, Instant::now() + SEQUENCE_DEADLINE)?; + return Ok(Vec::new()); + } if key.code != KeyCode::Esc || key.kind != KeyEventKind::Press { return Ok(vec![Event::Key(key)]); } @@ -312,7 +320,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 +616,47 @@ 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] + ); + } } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 7106f7f..3018633 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; @@ -866,6 +880,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 +914,106 @@ 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| 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) +} + +/// Attach source metadata, not pixels. Inline sections decode on viewport entry. +fn attach_inline_image_previews( + parsed: &mut crate::pager::side_by_side::ParsedDiff, + git: &GitCommands, + diff: &str, + old_revision: &str, + new_revision: Option<&str>, + excluded_paths: &std::collections::HashSet, +) { + use crate::pager::image_preview::{InlineImagePreview, InlineImageSource}; + let mut images = std::collections::HashMap::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), + }); + if let Some(image) = InlineImagePreview::new(git.repo_path(), old, new) { + images.insert(section, image); + } + } + parsed.attach_inline_images(images); +} + +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" + ) + }) +} + impl Gui { fn show_error(&mut self, title: &str, err: anyhow::Error) { self.popup = PopupState::Message { @@ -1117,6 +1249,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 +1289,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 @@ -1354,6 +1496,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); @@ -2933,6 +3087,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); @@ -2941,6 +3096,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 @@ -3051,6 +3239,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); @@ -3119,6 +3314,14 @@ impl Gui { if parsed.hunk_staged.len() != parsed.hunk_starts.len() { parsed.hunk_staged = vec![false; parsed.hunk_starts.len()]; } + attach_inline_image_previews( + &mut parsed, + &git, + &combined_diff, + "HEAD", + None, + &excluded, + ); if parsed.lines.is_empty() { DiffPayload::Empty } else { @@ -3275,12 +3478,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"); + attach_inline_image_previews( + &mut parsed, + &git, + &combined_diff, + &parent, + Some(&hash), + &HashSet::new(), + ); + DiffPayload::Parsed(parsed) } }); } else { @@ -9080,6 +9293,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 @@ -9109,6 +9324,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/pager/image_preview.rs b/src/pager/image_preview.rs new file mode 100644 index 0000000..d124645 --- /dev/null +++ b/src/pager/image_preview.rs @@ -0,0 +1,3116 @@ +//! 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. + +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::OnceLock; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use image::{DynamicImage, 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::cap_parser::{Parser, Response}; +use ratatui_image::picker::{Picker, ProtocolType}; +use ratatui_image::protocol::StatefulProtocolType; +use ratatui_image::thread::{ResizeRequest, ResizeResponse, ThreadProtocol}; +use ratatui_image::{Resize, ResizeEncodeRender, StatefulImage}; + +use super::side_by_side::DiffSideView; +use crate::config::Theme; + +const MAX_COMPRESSED_BYTES: usize = 20 * 1024 * 1024; +const MAX_IMAGE_DIMENSION: u32 = 16384; +const MAX_ALLOC_BYTES: u64 = 64 * 1024 * 1024; +const PREVIEW_MAX_WIDTH: u32 = 1600; +const PREVIEW_MAX_HEIGHT: u32 = 1200; +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(); +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(); +} + +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) + } +} + +/// 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(crate) fn width(&self) -> u32 { + self.image.width() + } + pub(crate) fn height(&self) -> u32 { + self.image.height() + } + pub(crate) fn format_label(&self) -> &'static str { + format_label_for(self.format) + } +} + +pub(crate) 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(crate) 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, + }) +} + +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. +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. +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)) +} + +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) +} + +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, + } + } + } +} + +/// 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(Clone)] +pub(crate) 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 { + pub(crate) 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::*; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use std::path::PathBuf; + + #[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")); + } + + 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/mod.rs b/src/pager/mod.rs index b7fb6be..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; diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index f285724..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(), @@ -698,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(), @@ -858,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, @@ -910,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(), @@ -925,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; @@ -960,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; @@ -1002,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 { @@ -1809,12 +1911,21 @@ 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. - if state - .lines - .iter() - .all(|line| line.preview_placeholder.is_some()) + // 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 @@ -1846,19 +1957,22 @@ 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 @@ -1889,12 +2003,17 @@ pub fn render_diff( } if let Some(message) = diff_line.preview_placeholder { - render_preview_placeholder( - buf, - Rect::new(inner.x, inner.y + row as u16, inner.width, 1), - message, - theme, - ); + // 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; } @@ -2077,12 +2196,17 @@ pub fn render_diff( .unwrap_or((&default_hl, &default_hl)); if let Some(message) = diff_line.preview_placeholder { - render_preview_placeholder( - buf, - Rect::new(inner.x, inner.y + row as u16, inner.width, 1), - message, - theme, - ); + // 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; } @@ -2405,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( @@ -2462,12 +2655,17 @@ fn render_unified_diff_body( if skip > 0 { skip -= 1; } else { - render_preview_placeholder( - buf, - Rect::new(inner.x, inner.y + row as u16, inner.width, 1), - message, - theme, - ); + // 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; @@ -3514,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(); @@ -3538,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() { @@ -3620,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. @@ -3721,6 +3909,37 @@ fn binary_file_placeholder_lines() -> Vec { .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). pub fn is_rename_only_diff(diff: &str) -> bool { rename_only_paths(diff).is_some() @@ -4511,4 +4730,210 @@ mod tests { ); 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!( + 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"); + } } From f327a09c4f75182326ffea9931e624765848d46c Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 6 Oct 2026 21:56:34 +0800 Subject: [PATCH 2/3] refactor(pager): isolate image preview capabilities and sources --- src/gui/controller/diff_mode.rs | 4 +- src/gui/mod.rs | 81 +-- src/pager/image_preview.rs | 657 +----------------------- src/pager/image_preview/capabilities.rs | 488 ++++++++++++++++++ src/pager/image_preview/inline_diff.rs | 174 +++++++ src/pager/image_preview/source.rs | 234 +++++++++ 6 files changed, 926 insertions(+), 712 deletions(-) create mode 100644 src/pager/image_preview/capabilities.rs create mode 100644 src/pager/image_preview/inline_diff.rs create mode 100644 src/pager/image_preview/source.rs diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index 91941c4..abe46ed 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -975,9 +975,9 @@ pub fn maybe_request_diff(gui: &mut Gui, generation: u64, diff_key: String) { } else { let mut parsed = DiffViewState::parse_diff_output(&dir_name, &combined_diff, 4, true); - super::super::attach_inline_image_previews( + crate::pager::image_preview::attach_inline_image_previews( &mut parsed, - &git, + git.repo_path(), &combined_diff, &ref_a, Some(&ref_b), diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 3018633..361b778 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -925,7 +925,10 @@ fn image_diff_payload( new: crate::pager::image_preview::ImageSource<'_>, ) -> Option { let paths = crate::git::diff::diff_paths_for_label(name); - if !paths.iter().any(|p| is_image_path(p)) { + 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)?; @@ -946,74 +949,6 @@ fn image_diff_payload( Some(parsed) } -/// Attach source metadata, not pixels. Inline sections decode on viewport entry. -fn attach_inline_image_previews( - parsed: &mut crate::pager::side_by_side::ParsedDiff, - git: &GitCommands, - diff: &str, - old_revision: &str, - new_revision: Option<&str>, - excluded_paths: &std::collections::HashSet, -) { - use crate::pager::image_preview::{InlineImagePreview, InlineImageSource}; - let mut images = std::collections::HashMap::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), - }); - if let Some(image) = InlineImagePreview::new(git.repo_path(), old, new) { - images.insert(section, image); - } - } - parsed.attach_inline_images(images); -} - -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" - ) - }) -} - impl Gui { fn show_error(&mut self, title: &str, err: anyhow::Error) { self.popup = PopupState::Message { @@ -3314,9 +3249,9 @@ impl Gui { if parsed.hunk_staged.len() != parsed.hunk_starts.len() { parsed.hunk_staged = vec![false; parsed.hunk_starts.len()]; } - attach_inline_image_previews( + crate::pager::image_preview::attach_inline_image_previews( &mut parsed, - &git, + git.repo_path(), &combined_diff, "HEAD", None, @@ -3485,9 +3420,9 @@ impl Gui { true, ); let parent = format!("{hash}^1"); - attach_inline_image_previews( + crate::pager::image_preview::attach_inline_image_previews( &mut parsed, - &git, + git.repo_path(), &combined_diff, &parent, Some(&hash), diff --git a/src/pager/image_preview.rs b/src/pager/image_preview.rs index d124645..d597ca5 100644 --- a/src/pager/image_preview.rs +++ b/src/pager/image_preview.rs @@ -5,40 +5,39 @@ //! 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::{Read, Write}; +use std::io::Write; use std::path::Path; -use std::sync::OnceLock; use std::sync::mpsc::{self, Receiver}; use std::time::{Duration, Instant}; -use image::{DynamicImage, ImageFormat}; +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::cap_parser::{Parser, Response}; 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; -const MAX_COMPRESSED_BYTES: usize = 20 * 1024 * 1024; -const MAX_IMAGE_DIMENSION: u32 = 16384; -const MAX_ALLOC_BYTES: u64 = 64 * 1024 * 1024; -const PREVIEW_MAX_WIDTH: u32 = 1600; -const PREVIEW_MAX_HEIGHT: u32 = 1200; -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(); static PENDING_DELETES: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); /// Flush scoped deletes on the UI thread, never on decoding/resize workers. @@ -51,589 +50,6 @@ pub fn flush_cleanup(out: &mut dyn Write) { let _ = out.flush(); } -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) - } -} - -/// 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(crate) fn width(&self) -> u32 { - self.image.width() - } - pub(crate) fn height(&self) -> u32 { - self.image.height() - } - pub(crate) fn format_label(&self) -> &'static str { - format_label_for(self.format) - } -} - -pub(crate) 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(crate) 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, - }) -} - -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. -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. -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)) -} - struct PreviewSide { width: u32, height: u32, @@ -834,34 +250,6 @@ pub fn load( ImagePreview::load(git, old, new) } -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, - } - } - } -} - /// 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 { @@ -1102,8 +490,8 @@ 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(Clone)] -pub(crate) enum InlineImageSource { +#[derive(Debug, Clone, PartialEq, Eq)] +enum InlineImageSource { Worktree(String), Revision { revision: String, path: String }, Missing, @@ -1137,7 +525,7 @@ pub struct InlineImagePreview { } impl InlineImagePreview { - pub(crate) fn new(repo: &Path, old: InlineImageSource, new: InlineImageSource) -> Option { + 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. @@ -1300,18 +688,13 @@ fn inline_placeholder(frame: &mut Frame, area: Rect, message: &str, theme: &Them #[cfg(test)] mod tests { + use super::source::*; use super::*; + use image::DynamicImage; use ratatui::Terminal; use ratatui::backend::TestBackend; use std::path::PathBuf; - #[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")); - } - fn halfblocks_picker() -> Picker { let mut p = Picker::from_fontsize((8, 16)); p.set_protocol_type(ProtocolType::Halfblocks); 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, + } + } + } +} From 17af5229bb121d37995bf72c6fd2c133275e6e57 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 6 Oct 2026 22:21:36 +0800 Subject: [PATCH 3/3] fix(input): preserve Alt shortcuts when filtering Kitty replies --- src/gui/input.rs | 285 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 274 insertions(+), 11 deletions(-) diff --git a/src/gui/input.rs b/src/gui/input.rs index 6a8425d..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; @@ -175,9 +180,7 @@ fn next_events_interruptible( /// 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; @@ -191,14 +194,13 @@ fn resolve_first_event(source: &mut impl EventSource, first: Event) -> io::Resul return Ok(vec![first]); }; // Crossterm parses an intact ESC _ (APC) as Alt+_, not as a control - // string. Without this guard, a Kitty reply's G opens Reset and its - // remaining `i=31;OK` payload gets typed into the dialog. + // string. Validate the reply rather than swallowing genuine Alt shortcuts + // and whatever input follows them. if key.kind == KeyEventKind::Press && key.modifiers == KeyModifiers::ALT - && matches!(key.code, KeyCode::Char(']' | 'P' | '^' | '_')) + && key.code == KeyCode::Char('_') { - consume_control_string(source, Instant::now() + SEQUENCE_DEADLINE)?; - return Ok(Vec::new()); + return resolve_kitty_reply(source, key); } if key.code != KeyCode::Esc || key.kind != KeyEventKind::Press { return Ok(vec![Event::Key(key)]); @@ -227,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()) } @@ -242,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(); @@ -659,4 +754,172 @@ mod tests { 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]); + } }