scm-record-0.5.0/.cargo_vcs_info.json0000644000000001500000000000100130700ustar { "git": { "sha1": "c23e31946e9a9d07ad72b0d380c80e922c3167c5" }, "path_in_vcs": "scm-record" }scm-record-0.5.0/Cargo.lock0000644000001034360000000000100110560ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 3 [[package]] name = "aho-corasick" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anes" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstyle" version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "assert_matches" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" [[package]] name = "autocfg" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "bumpalo" version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cassowary" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "castaway" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" dependencies = [ "rustversion", ] [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "ciborium" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" dependencies = [ "ciborium-io", "ciborium-ll", "serde", ] [[package]] name = "ciborium-io" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" [[package]] name = "ciborium-ll" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" dependencies = [ "ciborium-io", "half", ] [[package]] name = "clap" version = "4.5.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8eb5e908ef3a6efbe1ed62520fb7287959888c88485abe072543190ecc66783" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" version = "4.5.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96b01801b5fc6a0a232407abc821660c9c6d25a1cafc0d4f85f29fb8d9afc121" dependencies = [ "anstyle", "clap_lex", ] [[package]] name = "clap_lex" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "compact_str" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ "castaway", "cfg-if", "itoa", "rustversion", "ryu", "static_assertions", ] [[package]] name = "console" version = "0.15.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" dependencies = [ "encode_unicode", "libc", "once_cell", "windows-sys 0.59.0", ] [[package]] name = "criterion" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" dependencies = [ "anes", "cast", "ciborium", "clap", "criterion-plot", "is-terminal", "itertools 0.10.5", "num-traits", "once_cell", "oorandom", "plotters", "rayon", "regex", "serde", "serde_derive", "serde_json", "tinytemplate", "walkdir", ] [[package]] name = "criterion-plot" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", "itertools 0.10.5", ] [[package]] name = "crossbeam-deque" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ "crossbeam-epoch", "crossbeam-utils", ] [[package]] name = "crossbeam-epoch" version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", "mio", "parking_lot", "rustix", "signal-hook", "signal-hook-mio", "winapi", ] [[package]] name = "crossterm_winapi" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" dependencies = [ "winapi", ] [[package]] name = "crunchy" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" [[package]] name = "darling" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ "darling_core", "darling_macro", ] [[package]] name = "darling_core" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", "syn", ] [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", "syn", ] [[package]] name = "either" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "encode_unicode" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", "windows-sys 0.59.0", ] [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" [[package]] name = "getrandom" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", "wasi", ] [[package]] name = "half" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" dependencies = [ "cfg-if", "crunchy", ] [[package]] name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "indoc" version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" [[package]] name = "insta" version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6513e4067e16e69ed1db5ab56048ed65db32d10ba5fc1217f5393f8f17d8b5a5" dependencies = [ "console", "linked-hash-map", "once_cell", "similar", ] [[package]] name = "instability" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "894813a444908c0c8c0e221b041771d107c4a21de1d317dc49bcc66e3c9e5b3f" dependencies = [ "darling", "indoc", "proc-macro2", "quote", "syn", ] [[package]] name = "is-terminal" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" dependencies = [ "hermit-abi", "libc", "windows-sys 0.52.0", ] [[package]] name = "itertools" version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] [[package]] name = "itertools" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itoa" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "js-sys" version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" dependencies = [ "once_cell", "wasm-bindgen", ] [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" [[package]] name = "linked-hash-map" version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "lock_api" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", ] [[package]] name = "log" version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "lru" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ "hashbrown", ] [[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "mio" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", "log", "wasi", "windows-sys 0.52.0", ] [[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.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "oorandom" version = "11.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" [[package]] name = "parking_lot" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", "windows-targets", ] [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "plotters" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" dependencies = [ "num-traits", "plotters-backend", "plotters-svg", "wasm-bindgen", "web-sys", ] [[package]] name = "plotters-backend" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" [[package]] name = "plotters-svg" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" dependencies = [ "plotters-backend", ] [[package]] name = "ppv-lite86" version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ "zerocopy", ] [[package]] name = "proc-macro2" version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] [[package]] name = "proptest" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14cae93065090804185d3b75f0bf93b8eeda30c7a9b4a33d3bdb3988d6229e50" dependencies = [ "bit-set", "bit-vec", "bitflags", "lazy_static", "num-traits", "rand", "rand_chacha", "rand_xorshift", "regex-syntax", "rusty-fork", "tempfile", "unarray", ] [[package]] name = "quick-error" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" dependencies = [ "proc-macro2", ] [[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 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 = "rand_xorshift" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" dependencies = [ "rand_core", ] [[package]] name = "ratatui" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags", "cassowary", "compact_str", "crossterm", "indoc", "instability", "itertools 0.13.0", "lru", "paste", "strum", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", ] [[package]] name = "rayon" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" dependencies = [ "either", "rayon-core", ] [[package]] name = "rayon-core" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" dependencies = [ "crossbeam-deque", "crossbeam-utils", ] [[package]] name = "redox_syscall" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ "bitflags", ] [[package]] name = "regex" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rustix" version = "0.38.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", "windows-sys 0.59.0", ] [[package]] name = "rustversion" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" [[package]] name = "rusty-fork" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" dependencies = [ "fnv", "quick-error", "tempfile", "wait-timeout", ] [[package]] name = "ryu" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ "winapi-util", ] [[package]] name = "scm-record" version = "0.5.0" dependencies = [ "assert_matches", "cassowary", "criterion", "crossterm", "insta", "num-traits", "proptest", "ratatui", "serde", "serde_json", "thiserror", "tracing", "unicode-width 0.2.0", ] [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_json" version = "1.0.135" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b0d7ba2887406110130a978386c4e1befb98c674b4fba677954e4db976630d9" dependencies = [ "itoa", "memchr", "ryu", "serde", ] [[package]] name = "signal-hook" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" dependencies = [ "libc", "signal-hook-registry", ] [[package]] name = "signal-hook-mio" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", "mio", "signal-hook", ] [[package]] name = "signal-hook-registry" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ "libc", ] [[package]] name = "similar" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" [[package]] name = "smallvec" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ "heck", "proc-macro2", "quote", "rustversion", "syn", ] [[package]] name = "syn" version = "2.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46f71c0377baf4ef1cc3e3402ded576dccc315800fbc62dfc7fe04b009773b4a" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "tempfile" version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" dependencies = [ "cfg-if", "fastrand", "getrandom", "once_cell", "rustix", "windows-sys 0.59.0", ] [[package]] name = "thiserror" version = "2.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3ac7f54ca534db81081ef1c1e7f6ea8a3ef428d2fc069097c079443d24124d3" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "2.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9465d30713b56a37ede7185763c3492a91be2f5fa68d958c44e41ab9248beb" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tinytemplate" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" dependencies = [ "serde", "serde_json", ] [[package]] name = "tracing" version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", "tracing-attributes", "tracing-core", ] [[package]] name = "tracing-attributes" version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tracing-core" version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", ] [[package]] name = "unarray" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools 0.13.0", "unicode-segmentation", "unicode-width 0.1.14", ] [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "wait-timeout" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" dependencies = [ "libc", ] [[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", ] [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" dependencies = [ "cfg-if", "once_cell", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" dependencies = [ "bumpalo", "log", "proc-macro2", "quote", "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" dependencies = [ "proc-macro2", "quote", "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" [[package]] name = "web-sys" version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ "winapi-i686-pc-windows-gnu", "winapi-x86_64-pc-windows-gnu", ] [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets", ] [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "zerocopy" version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", "zerocopy-derive", ] [[package]] name = "zerocopy-derive" version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", "syn", ] scm-record-0.5.0/Cargo.toml0000644000000037660000000000100111060ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" rust-version = "1.74" name = "scm-record" version = "0.5.0" authors = ["Waleed Khan "] description = "UI component to interactively select changes to include in a commit." license = "MIT OR Apache-2.0" repository = "https://github.com/arxanas/scm-record" [[package.metadata.release.pre-release-replacements]] file = "CHANGELOG.md" min = 1 replace = "{{version}}" search = "Unreleased" [[package.metadata.release.pre-release-replacements]] file = "CHANGELOG.md" min = 1 replace = "{{date}}" search = "ReleaseDate" [[package.metadata.release.pre-release-replacements]] exactly = 1 file = "CHANGELOG.md" replace = """ ## [Unreleased] - ReleaseDate """ search = "" [[bench]] name = "benches" harness = false [dependencies.cassowary] version = "0.3" [dependencies.crossterm] version = "0.28" [dependencies.num-traits] version = "0.2" [dependencies.ratatui] version = "0.29.0" [dependencies.serde] version = "1.0" features = ["serde_derive"] optional = true [dependencies.serde_json] version = "1.0" optional = true [dependencies.thiserror] version = "2.0" [dependencies.tracing] version = "0.1" [dependencies.unicode-width] version = "0.2" [dev-dependencies.assert_matches] version = "1.5" [dev-dependencies.criterion] version = "0.5" [dev-dependencies.insta] version = "1.41" [dev-dependencies.proptest] version = "1.6.0" [dev-dependencies.serde_json] version = "1.0" [features] debug = ["serde"] default = ["debug"] serde = [ "dep:serde", "dep:serde_json", ] scm-record-0.5.0/Cargo.toml.orig000064400000000000000000000027171046102023000145620ustar 00000000000000[package] authors = ["Waleed Khan "] description = "UI component to interactively select changes to include in a commit." edition = "2021" license = "MIT OR Apache-2.0" name = "scm-record" repository = "https://github.com/arxanas/scm-record" version = "0.5.0" # Main consumers to consider: # - git-branchless: https://github.com/arxanas/git-branchless/blob/master/Cargo.toml # - jj: https://github.com/martinvonz/jj/blob/main/Cargo.toml rust-version = "1.74" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] debug = ["serde"] default = ["debug"] serde = ["dep:serde", "dep:serde_json"] [dependencies] cassowary = "0.3" crossterm = "0.28" num-traits = "0.2" thiserror = "2.0" tracing = "0.1" ratatui = "0.29.0" unicode-width = "0.2" # Features: serde serde = { version = "1.0", features = ["serde_derive"], optional = true } serde_json = { version = "1.0", optional = true } [dev-dependencies] assert_matches = "1.5" criterion = "0.5" insta = "1.41" proptest = "1.6.0" serde_json = "1.0" [[bench]] name = "benches" harness = false [package.metadata.release] pre-release-replacements = [ { file = "CHANGELOG.md", search = "Unreleased", replace = "{{version}}", min = 1 }, { file = "CHANGELOG.md", search = "ReleaseDate", replace = "{{date}}", min = 1 }, { file = "CHANGELOG.md", search = "", replace = "\n## [Unreleased] - ReleaseDate\n", exactly = 1 }, ] scm-record-0.5.0/LICENSE-APACHE000064400000000000000000000261361046102023000136200ustar 00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. scm-record-0.5.0/LICENSE-MIT000064400000000000000000000020531046102023000133200ustar 00000000000000Copyright (c) 2021 Individual contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. scm-record-0.5.0/benches/benches.rs000064400000000000000000000032411046102023000152500ustar 00000000000000use std::{borrow::Cow, path::Path}; use criterion::{criterion_group, criterion_main, BatchSize, Criterion}; use scm_record::{ helpers::TestingInput, ChangeType, Event, File, RecordState, Recorder, Section, SectionChangedLine, }; fn bench_record(c: &mut Criterion) { c.bench_function("scm_record: toggle line", |b| { let before_line = SectionChangedLine { line: Cow::Borrowed("foo"), is_checked: false, change_type: ChangeType::Removed, }; let after_line = SectionChangedLine { line: Cow::Borrowed("foo"), is_checked: false, change_type: ChangeType::Added, }; let record_state = RecordState { is_read_only: false, commits: Default::default(), files: vec![File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![Section::Changed { lines: [vec![before_line; 1000], vec![after_line; 1000]].concat(), }], }], }; let mut input = TestingInput::new( 80, 24, [Event::ToggleItem, Event::ToggleItem, Event::QuitAccept], ); b.iter_batched( || record_state.clone(), |record_state| { let recorder = Recorder::new(record_state, &mut input); recorder.run() }, BatchSize::PerIteration, ) }); } criterion_group!( name = benches; config = Criterion::default().sample_size(10); targets = bench_record, ); criterion_main!(benches); scm-record-0.5.0/examples/load_json.rs000064400000000000000000000043631046102023000160260ustar 00000000000000#![warn(clippy::all, clippy::as_conversions)] #![allow(clippy::too_many_arguments)] use std::path::Path; use scm_record::{helpers::CrosstermInput, RecordError, RecordState, Recorder, SelectedContents}; #[cfg(feature = "serde")] fn load_state(path: impl AsRef) -> RecordState<'static> { let json_file = std::fs::File::open(path).expect("opening JSON file"); serde_json::from_reader(json_file).expect("deserializing state") } #[cfg(not(feature = "serde"))] fn load_state(_path: impl AsRef) -> RecordState<'static> { panic!("load_json example requires `serde` feature") } fn main() { let args: Vec = std::env::args().collect(); let json_filename = args.get(1).expect("expected JSON dump as first argument"); let record_state: RecordState = load_state(json_filename); let mut input = CrosstermInput; let recorder = Recorder::new(record_state, &mut input); let result = recorder.run(); match result { Ok(result) => { let RecordState { is_read_only: _, commits: _, files, } = result; for file in files { println!("--- Path {:?} final lines: ---", file.path); let (selected, _unselected) = file.get_selected_contents(); print!( "{}", match &selected { SelectedContents::Absent => "\n".to_string(), SelectedContents::Unchanged => "".to_string(), SelectedContents::Binary { old_description: _, new_description: None, } => "\n".to_string(), SelectedContents::Binary { old_description: _, new_description: Some(description), } => format!("\n"), SelectedContents::Present { contents } => contents.clone(), } ); } } Err(RecordError::Cancelled) => println!("Cancelled!\n"), Err(err) => { println!("Error: {err}"); } } } scm-record-0.5.0/examples/static_contents.rs000064400000000000000000000122061046102023000172550ustar 00000000000000#![warn(clippy::all, clippy::as_conversions)] #![allow(clippy::too_many_arguments)] use std::borrow::Cow; use std::path::Path; use scm_record::{ helpers::CrosstermInput, ChangeType, File, RecordError, RecordState, Recorder, Section, SectionChangedLine, SelectedContents, }; fn main() { let files = vec![ File { old_path: None, path: Cow::Borrowed(Path::new("foo/bar")), file_mode: None, sections: vec![ Section::Unchanged { lines: std::iter::repeat(Cow::Borrowed("this is some text\n")) .take(20) .collect(), }, Section::Changed { lines: vec![ SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("before text 1\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("before text 2\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Added, line: Cow::Borrowed("after text 1\n"), }, SectionChangedLine { is_checked: false, change_type: ChangeType::Added, line: Cow::Borrowed("after text 2\n"), }, ], }, Section::Unchanged { lines: vec![Cow::Borrowed("this is some trailing text\n")], }, ], }, File { old_path: None, path: Cow::Borrowed(Path::new("baz")), file_mode: None, sections: vec![ Section::Unchanged { lines: vec![ Cow::Borrowed("Some leading text 1\n"), Cow::Borrowed("Some leading text 2\n"), ], }, Section::Changed { lines: vec![ SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("before text 1\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("before text 2\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Added, line: Cow::Borrowed("after text 1\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Added, line: Cow::Borrowed("after text 2\n"), }, ], }, Section::Unchanged { lines: vec![Cow::Borrowed("this is some trailing text")], }, ], }, ]; let record_state = RecordState { is_read_only: false, commits: Default::default(), files, }; let mut input = CrosstermInput; let recorder = Recorder::new(record_state, &mut input); let result = recorder.run(); match result { Ok(result) => { let RecordState { is_read_only: _, commits: _, files, } = result; for file in files { println!("--- Path {:?} final lines: ---", file.path); let (selected, _unselected) = file.get_selected_contents(); print!( "{}", match &selected { SelectedContents::Absent => "\n".to_string(), SelectedContents::Binary { old_description: _, new_description: None, } => "\n".to_string(), SelectedContents::Binary { old_description: _, new_description: Some(description), } => format!("\n"), SelectedContents::Present { contents } => { contents.clone() } SelectedContents::Unchanged => "".to_string(), } ); } } Err(RecordError::Cancelled) => println!("Cancelled!\n"), Err(err) => { println!("Error: {err}"); } } } scm-record-0.5.0/proptest-regressions/ui.txt000064400000000000000000000012021046102023000172560ustar 00000000000000# Seeds for failure cases proptest has generated in the past. It is # automatically read and these particular cases re-run before any # novel cases are generated. # # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc 4ad364b7a4bfbb4cf16c5dac6dcfbb3e014356930cd855d76f110aed3856a9ed # shrinks to line = "¡" cc b7d37d182f61b73b6ef4fcd469caff5ccd21379903ce66bd02e14e1abc9344dd # shrinks to line = "\t" cc de75c8131f6e198916f45226f06c6a09f6cf805a464bbf6954b1a3b03b0c7940 # shrinks to line = "\0" cc e1eedb87f79680aebd62a9f3f94e31c009e12ab13b855695eb0ce793f43d29aa scm-record-0.5.0/src/consts.rs000064400000000000000000000010711046102023000143310ustar 00000000000000//! Special runtime variables. /// Upon launch, write a serialized version of the UI state to the file named /// [`DUMP_UI_STATE_FILENAME`] in the current directory. Only works if compiled /// with the `debug` feature. pub const ENV_VAR_DUMP_UI_STATE: &str = "SCM_RECORD_DUMP_UI_STATE"; /// The filename to write to for [`ENV_VAR_DUMP_UI_STATE`]. pub const DUMP_UI_STATE_FILENAME: &str = "scm_record_ui_state.json"; /// Render a debug pane over the file. Only works if compiled with the `debug` /// feature. pub const ENV_VAR_DEBUG_UI: &str = "SCM_RECORD_DEBUG_UI"; scm-record-0.5.0/src/helpers.rs000064400000000000000000000056761046102023000145010ustar 00000000000000//! Helper functions for rendering UI components. use std::{collections::VecDeque, time::Duration}; use crate::{Event, RecordError, RecordInput, TerminalKind}; /// Generate a one-line description of a binary file change. pub fn make_binary_description(hash: &str, num_bytes: u64) -> String { format!("{} ({} bytes)", hash, num_bytes) } /// Reads input events from the terminal using `crossterm`. /// /// Its default implementation of `edit_commit_message` returns the provided /// message unchanged. pub struct CrosstermInput; impl RecordInput for CrosstermInput { fn terminal_kind(&self) -> TerminalKind { TerminalKind::Crossterm } fn next_events(&mut self) -> Result, RecordError> { // Ensure we block for at least one event. let first_event = crossterm::event::read().map_err(RecordError::ReadInput)?; let mut events = vec![first_event.into()]; // Some events, like scrolling, are generated more quickly than // we can render the UI. In those cases, batch up all available // events and process them before the next render. while crossterm::event::poll(Duration::ZERO).map_err(RecordError::ReadInput)? { let event = crossterm::event::read().map_err(RecordError::ReadInput)?; events.push(event.into()); } Ok(events) } fn edit_commit_message(&mut self, message: &str) -> Result { Ok(message.to_owned()) } } /// Reads events from the provided sequence of events. pub struct TestingInput { /// The width of the virtual terminal in columns. pub width: usize, /// The height of the virtual terminal in columns. pub height: usize, /// The sequence of events to emit. pub events: Box>, /// Commit messages to use when the commit editor is opened. pub commit_messages: VecDeque, } impl TestingInput { /// Helper function to construct a `TestingInput`. pub fn new( width: usize, height: usize, events: impl IntoIterator + 'static, ) -> Self { Self { width, height, events: Box::new(events.into_iter()), commit_messages: Default::default(), } } } impl RecordInput for TestingInput { fn terminal_kind(&self) -> TerminalKind { let Self { width, height, events: _, commit_messages: _, } = self; TerminalKind::Testing { width: *width, height: *height, } } fn next_events(&mut self) -> Result, RecordError> { Ok(vec![self.events.next().unwrap_or(Event::None)]) } fn edit_commit_message(&mut self, _message: &str) -> Result { self.commit_messages .pop_front() .ok_or_else(|| RecordError::Other("No more commit messages available".to_string())) } } scm-record-0.5.0/src/lib.rs000064400000000000000000000010471046102023000135710ustar 00000000000000//! Reusable change selector UI for source control systems. #![warn(missing_docs)] #![warn( clippy::all, clippy::as_conversions, clippy::clone_on_ref_ptr, clippy::dbg_macro )] #![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] mod render; mod types; mod ui; mod util; pub mod consts; pub mod helpers; pub use types::{ ChangeType, Commit, File, FileMode, RecordError, RecordState, Section, SectionChangedLine, SelectedContents, }; pub use ui::{Event, RecordInput, Recorder, TerminalKind, TestingScreenshot}; scm-record-0.5.0/src/render.rs000064400000000000000000000530261046102023000143060ustar 00000000000000use std::borrow::Cow; use std::cmp::{max, min}; use std::collections::HashMap; use std::fmt::Debug; use std::hash::Hash; use std::mem; use cassowary::{Solver, Variable}; use num_traits::cast; use ratatui::buffer::Buffer; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{StatefulWidget, Widget}; use ratatui::Frame; use unicode_width::UnicodeWidthStr; use crate::util::{IsizeExt, UsizeExt}; #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct RectSize { pub width: usize, pub height: usize, } impl From for RectSize { fn from(rect: ratatui::layout::Rect) -> Self { Rect::from(rect).into() } } impl From for RectSize { fn from(rect: Rect) -> Self { let Rect { x: _, y: _, width, height, } = rect; Self { width, height } } } /// Like `ratatui::layout::Rect`, but supports addressing negative coordinates. (These /// coordinates shouldn't be rendered.) #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct Rect { pub x: isize, pub y: isize, pub width: usize, pub height: usize, } impl From for Rect { fn from(value: ratatui::layout::Rect) -> Self { let ratatui::layout::Rect { x, y, width, height, } = value; Self { x: x.try_into().unwrap(), y: y.try_into().unwrap(), width: width.into(), height: height.into(), } } } impl Rect { pub fn end_x(self) -> isize { self.x + self.width.unwrap_isize() } pub fn end_y(self) -> isize { self.y + self.height.unwrap_isize() } pub fn iter_ys(self) -> impl Iterator { self.y..self.end_y() } pub fn top_row(self) -> Rect { Rect { x: self.x, y: self.y, width: self.width, height: 1, } } /// The (x, y) coordinate of the top-left corner of this `Rect`. fn top_left(self) -> (isize, isize) { (self.x, self.y) } /// The (x, y) coordinate of the bottom-right corner of this `Rect`. fn bottom_right(self) -> (isize, isize) { (self.end_x(), self.end_y()) } /// Whether or not this `Rect` contains the given point. pub fn contains_point(self, x: isize, y: isize) -> bool { let (x1, y1) = self.top_left(); let (x2, y2) = self.bottom_right(); x1 <= x && x < x2 && y1 <= y && y < y2 } /// Whether this `Rect` has zero area. pub fn is_empty(self) -> bool { self.width == 0 || self.height == 0 } /// The largest `Rect` which is contained completely within both `self` and /// `other`. pub fn intersect(self, other: Self) -> Self { let (self_x1, self_y1) = self.top_left(); let (self_x2, self_y2) = self.bottom_right(); let (other_x1, other_y1) = other.top_left(); let (other_x2, other_y2) = other.bottom_right(); let x1 = max(self_x1, other_x1); let y1 = max(self_y1, other_y1); let x2 = min(self_x2, other_x2); let y2 = min(self_y2, other_y2); let width = max(0, x2 - x1); let height = max(0, y2 - y1); Self { x: x1, y: y1, width: width.unwrap_usize(), height: height.unwrap_usize(), } } /// The smallest `Rect` which contains both `self` and `other`. Note that if /// one of `self` or `other` is empty, the other is returned, i.e. we don't /// try to calculate the bounding box which includes a zero-area point. pub fn union_bounding(self, other: Rect) -> Rect { if self.is_empty() { other } else if other.is_empty() { self } else { let (self_x1, self_y1) = self.top_left(); let (self_x2, self_y2) = self.bottom_right(); let (other_x1, other_y1) = other.top_left(); let (other_x2, other_y2) = other.bottom_right(); let x1 = min(self_x1, other_x1); let y1 = min(self_y1, other_y1); let x2 = max(self_x2, other_x2); let y2 = max(self_y2, other_y2); let width = max(0, x2 - x1); let height = max(0, y2 - y1); Self { x: x1, y: y1, width: width.unwrap_usize(), height: height.unwrap_usize(), } } } } /// Create a centered `Rect` of at least the given size and at most the provided /// percentages. pub(crate) fn centered_rect( rect: Rect, min_size: RectSize, max_percent_width: usize, max_percent_height: usize, ) -> Rect { // `tui` has a `Layout` system that wraps `cassowary`, but it doesn't seem // to be flexible enough to express the constraints that we want? For // example, there's no way to express that the width needs to have a minimum // size *and* a preferred size. use cassowary::strength::*; use cassowary::WeightedRelation::*; let Rect { x: min_x, y: min_y, width: max_width, height: max_height, } = rect; let min_x: f64 = cast(min_x).unwrap(); let min_y: f64 = cast(min_y).unwrap(); let max_width: f64 = cast(max_width).unwrap(); let max_height: f64 = cast(max_height).unwrap(); let max_x = min_x + max_width; let max_y = min_y + max_height; let max_percent_width: f64 = cast(max_percent_width).unwrap(); let max_percent_height: f64 = cast(max_percent_height).unwrap(); let preferred_width: f64 = max_percent_width * max_width / 100.0; let preferred_height: f64 = max_percent_height * max_height / 100.0; let RectSize { width: min_width, height: min_height, } = min_size; let min_width: f64 = cast(min_width).unwrap(); let min_height: f64 = cast(min_height).unwrap(); let mut solver = Solver::new(); let x = Variable::new(); let y = Variable::new(); let width = Variable::new(); let height = Variable::new(); solver .add_constraints(&[ width | GE(REQUIRED) | min_width, height | GE(REQUIRED) | min_height, width | LE(REQUIRED) | max_width, height | LE(REQUIRED) | max_height, width | EQ(WEAK) | preferred_width, height | EQ(WEAK) | preferred_height, ]) .unwrap(); solver .add_constraints(&[ x | GE(REQUIRED) | min_x, y | GE(REQUIRED) | min_y, x | LE(REQUIRED) | max_x, y | LE(REQUIRED) | max_y, ]) .unwrap(); solver .add_constraints(&[ (x - min_x) | EQ(MEDIUM) | (max_x - (x + width)), (y - min_y) | EQ(MEDIUM) | (max_y - (y + height)), ]) .unwrap(); let changes: HashMap = solver.fetch_changes().iter().copied().collect(); Rect { x: cast(changes.get(&x).unwrap_or(&0.0).floor()).unwrap(), y: cast(changes.get(&y).unwrap_or(&0.0).floor()).unwrap(), width: cast(changes.get(&width).unwrap_or(&0.0).floor()).unwrap(), height: cast(changes.get(&height).unwrap_or(&0.0).floor()).unwrap(), } } /// A "half-open" `Rect` used to to restrict drawing to a certain portion of the screen. #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct Mask { pub x: isize, pub y: isize, /// If `None`, the mask is unrestricted on the x-axis past the `x` value. pub width: Option, /// If `None`, the mask is unrestricted on the y-axis past the `y` value. pub height: Option, } impl Mask { /// Restrict the `Rect` size to be only the portion that is inside the mask. pub fn apply(self, rect: Rect) -> Rect { let end_x = self.end_x().unwrap_or_else(|| rect.end_x()); let end_y = self.end_y().unwrap_or_else(|| rect.end_y()); let width = (end_x - self.x).clamp_into_usize(); let height = (end_y - self.y).clamp_into_usize(); let mask_rect = Rect { x: self.x, y: self.y, width, height, }; mask_rect.intersect(rect) } pub fn end_x(self) -> Option { self.width.map(|width| self.x + width.unwrap_isize()) } pub fn end_y(self) -> Option { self.height.map(|height| self.y + height.unwrap_isize()) } } impl From for Mask { fn from(rect: Rect) -> Self { let Rect { x, y, width, height, } = rect; Self { x, y, width: Some(width), height: Some(height), } } } /// Recording of where the component with a certain ID drew on the virtual /// canvas. #[derive(Debug)] struct DrawTrace { /// The bounding box of all cells where the component drew. /// /// This `Rect` is at least as big as the bounding box containing all child /// component `Rect`s, and could be bigger if the component drew somewhere /// to the screen where no child component drew. rect: Rect, /// The bounding boxes of where each child component drew. components: HashMap, } impl DrawTrace { /// Update the bounding box of this trace to include `other_rect`. pub fn merge_rect(&mut self, other_rect: Rect) { let Self { rect, components: _, } = self; *rect = rect.union_bounding(other_rect) } /// Update the bounding box of this trace to include `other.rect` and copy /// all child component `Rect`s. pub fn merge(&mut self, other: Self) { let Self { rect, components } = self; let Self { rect: other_rect, components: other_components, } = other; *rect = rect.union_bounding(other_rect); for (id, rect) in other_components { components.insert(id.clone(), rect); } } } impl Default for DrawTrace { fn default() -> Self { Self { rect: Default::default(), components: Default::default(), } } } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct DrawnRect { pub rect: Rect, pub timestamp: usize, } pub(crate) type DrawnRects = HashMap; /// Accessor to draw on the virtual canvas. The caller can draw anywhere on the /// canvas, but the actual renering will be restricted to this viewport. All /// draw calls are also tracked so that we know where each component was drawn /// after the fact (see `DrawTrace`). #[derive(Debug)] pub(crate) struct Viewport<'a, ComponentId> { buf: &'a mut Buffer, rect: Rect, mask: Option, timestamp: usize, trace: Vec>, debug_messages: Vec, } impl<'a, ComponentId: Clone + Debug + Eq + Hash> Viewport<'a, ComponentId> { pub fn new(buf: &'a mut Buffer, rect: Rect) -> Self { Self { buf, rect, mask: Default::default(), timestamp: Default::default(), trace: vec![Default::default()], debug_messages: Default::default(), } } /// The portion of the virtual canvas that will be rendered to the terminal. /// Thus, this `Rect` should have the same dimensions as the terminal. pub fn rect(&self) -> Rect { self.rect } /// The mask used for rendering. Calls to `draw_span` will only render /// inside the mask area. This can be used to overlay one component on top /// of another in a fixed area. /// /// This can be set with `Viewport::with_mask`. If no mask has been set in /// the current call stack, then the returned value defaults to /// `Viewport::rect`, i.e. the area representing the entire terminal. pub fn mask(&self) -> Mask { self.mask.unwrap_or_else(|| self.rect().into()) } /// Get the masked area restricted to the portion that is viewable in the /// viewport. This lets us return a `Rect` instead of a `Mask`, which could /// otherwise have `None` `width` or `height` fields. pub fn mask_rect(&self) -> Rect { self.mask().apply(self.rect()) } /// Render the provided component using the given `Frame`. Returns a mapping /// indicating where each component was drawn on the screen. pub fn render_top_level( frame: &mut Frame, x: isize, y: isize, component: &C, ) -> DrawnRects { let widget = TopLevelWidget { component, x, y }; let term_area = frame.area(); let mut drawn_rects = Default::default(); frame.render_stateful_widget(widget, term_area, &mut drawn_rects); drawn_rects } fn current_trace_mut(&mut self) -> &mut DrawTrace { self.trace.last_mut() .expect("draw trace stack is empty, so can't update trace for current component; did you call `Viewport::render_top_level` to render the top-level component?") } /// Set the terminal styling for a certain area. This can also be /// accomplished using `draw_span` with a styled `Span`, but in some cases, /// it may be more appropriate to set the style of certain cells directly. pub fn set_style(&mut self, rect: Rect, style: Style) { self.buf.set_style(self.translate_rect(rect), style); self.current_trace_mut().merge_rect(rect); } /// Render a debug message to the screen (at an unspecified location). pub fn debug(&mut self, message: impl Into) { self.debug_messages.push(message.into()) } /// Set a mask to be used for rendering inside `f`. pub fn with_mask(&mut self, mask: Mask, f: impl FnOnce(&mut Self) -> T) -> T { let mut mask = Some(mask); mem::swap(&mut self.mask, &mut mask); let result = f(self); mem::swap(&mut self.mask, &mut mask); result } /// Draw the provided child component to the screen at the given `(x, y)` /// location. pub fn draw_component>( &mut self, x: isize, y: isize, component: &C, ) -> Rect { let timestamp = { let timestamp = self.timestamp; self.timestamp += 1; timestamp }; let mut trace = { self.trace.push(Default::default()); component.draw(self, x, y); self.trace.pop().unwrap() }; let trace_rect = trace.components.values().fold(trace.rect, |acc, elem| { let DrawnRect { rect, timestamp: _ } = elem; acc.union_bounding(*rect) }); trace.rect = trace_rect; trace.components.insert( component.id(), DrawnRect { rect: trace_rect, timestamp, }, ); self.current_trace_mut().merge(trace); trace_rect } /// Draw a `Span` directly to the screen at the given `(x, y)` location. pub fn draw_span(&mut self, x: isize, y: isize, span: &Span) -> Rect { let Span { content, style } = span; let span_rect = Rect { x, y, width: content.width(), height: 1, }; self.current_trace_mut().merge_rect(span_rect); let draw_rect = self.rect.intersect(span_rect); let draw_rect = match self.mask { Some(mask) => mask.apply(draw_rect), None => draw_rect, }; if !draw_rect.is_empty() { let span_start_idx = (draw_rect.x - span_rect.x).unwrap_usize(); let span_start_byte_idx = content .char_indices() .nth(span_start_idx) .map(|(i, _c)| i) .unwrap_or(0); let span_end_byte_idx = match content .char_indices() .nth(span_start_idx + draw_rect.width) .map(|(i, _c)| i) { Some(span_end_byte_index) => span_end_byte_index, None => content.len(), }; let draw_span = Span { content: Cow::Borrowed(&content.as_ref()[span_start_byte_idx..span_end_byte_idx]), style: *style, }; let buf_rect = self.translate_rect(draw_rect); self.buf .set_span(buf_rect.x, buf_rect.y, &draw_span, buf_rect.width); } span_rect } /// Draw a [`Line`] directly to the screen at `(x, y)` location. pub fn draw_line(&mut self, x: isize, y: isize, line: &Line) -> Rect { let line_rect = Rect { x, y, width: line.width(), height: 1, }; self.current_trace_mut().merge_rect(line_rect); let draw_rect = self.rect.intersect(line_rect); let draw_rect = match self.mask { Some(mask) => mask.apply(draw_rect), None => draw_rect, }; if !draw_rect.is_empty() { let buf_rect = self.translate_rect(draw_rect); line.render(buf_rect, self.buf); } line_rect } /// Draw the given text. If the text would overflow the current mask, then /// it is truncated with an ellipsis. pub fn draw_text<'line>(&mut self, x: isize, y: isize, line: impl Into>) -> Rect { let line_rect = self.draw_line(x, y, &line.into()); let mask_rect = self.mask_rect(); if line_rect.end_x() > mask_rect.end_x() { self.draw_span(mask_rect.end_x() - 1, line_rect.y, &Span::raw("…")); } line_rect } pub fn draw_widget(&mut self, rect: ratatui::layout::Rect, widget: impl Widget) { self.current_trace_mut().merge_rect(rect.into()); widget.render(rect, self.buf); } pub fn draw_blank(&mut self, rect: Rect) { for y in rect.iter_ys() { self.draw_span( rect.x, y, &Span::styled(" ".repeat(rect.width), Style::reset()), ); } } /// Convert the virtual `Rect` being displayed on the viewport, potentially /// including an area off-screen, into a real terminal `ratatui::layout::Rect` /// indicating the actual positions of the characters to be printed /// on-screen. pub fn translate_rect(&self, rect: impl Into) -> ratatui::layout::Rect { let draw_rect = self.rect.intersect(rect.into()); let x = draw_rect.x - self.rect.x; let y = draw_rect.y - self.rect.y; let width = draw_rect.width; let height = draw_rect.height; ratatui::layout::Rect { x: x.try_into().unwrap(), y: y.try_into().unwrap(), width: width.try_into().unwrap(), height: height.try_into().unwrap(), } } } /// Wrapper to render via `ratatui::Frame`. struct TopLevelWidget<'a, C> { component: &'a C, x: isize, y: isize, } impl StatefulWidget for TopLevelWidget<'_, C> { type State = DrawnRects; fn render(self, area: ratatui::layout::Rect, buf: &mut Buffer, state: &mut Self::State) { let Self { component, x, y } = self; let mut viewport: Viewport = Viewport::new( buf, Rect { x, y, width: area.width.into(), height: area.height.into(), }, ); viewport.draw_component(0, 0, component); *state = viewport.trace.pop().unwrap().components; debug_assert!(viewport.trace.is_empty()); // Render debug messages. { let x = 50_u16; let debug_messages: Vec = viewport .debug_messages .into_iter() .flat_map(|message| -> Vec { message.split('\n').map(|s| s.to_string()).collect() }) .collect(); let max_line_len = min( debug_messages.iter().map(|s| s.len()).max().unwrap_or(0), viewport.buf.area.width.into(), ); for (y, message) in debug_messages.into_iter().enumerate() { let spaces = " ".repeat(max_line_len - message.len()); let span = Span::styled( message + &spaces, Style::default() .fg(Color::Yellow) .add_modifier(Modifier::REVERSED), ); if y < viewport.buf.area.height.into() { viewport.buf.set_span( x, y.clamp_into_u16(), &span, max_line_len.clamp_into_u16(), ); } } } } } /// A component which can be rendered on the virtual canvas. All calls to draw /// components are traced so that it can be determined later where a given /// component was drawn. pub(crate) trait Component: Sized { /// A unique identifier which identifies this component or one of its child /// components. This can be used with the return value of /// `Viewport::render_top_level` to find where the component with a given ID /// was drawn. type Id: Clone + Debug + Eq + Hash; /// Get the ID for this component. fn id(&self) -> Self::Id; /// Draw this component and any child components. fn draw(&self, viewport: &mut Viewport, x: isize, y: isize); } scm-record-0.5.0/src/types.rs000064400000000000000000000457301046102023000141760ustar 00000000000000//! Data types for the change selector interface. use std::borrow::Cow; use std::fmt::Display; use std::io; use std::num::TryFromIntError; use std::path::Path; use thiserror::Error; /// The state used to render the changes. This is passed into /// [`crate::Recorder::new`] and then updated and returned with /// [`crate::Recorder::run`]. #[derive(Clone, Debug, Default, Eq, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct RecordState<'a> { /// Render the UI as read-only, such that the checkbox states cannot be /// changed by the user. pub is_read_only: bool, /// The commits containing the selected changes. Each changed section be /// assigned to exactly one commit. /// /// If there are fewer than two commits in this list, then it is padded to /// two commits using `Commit::default` before being returned. /// /// It's important to note that the `Commit`s do not literally contain the /// selected changes. They are stored out-of-band in the `files` field. It /// would be possible to store the changes in the `Commit`s, but we would no /// longer get the invariant that each change belongs to a single commit for /// free. (That being said, we now have to uphold the invariant that the /// changes are all assigned to valid commits.) It would also be somewhat /// more tedious to write the code that removes the change from one `Commit` /// and adds it to the correct relative position (with respect to all of the /// other changes) in another `Commit`. pub commits: Vec, /// The state of each file. This is rendered in order, so you may want to /// sort this list by path before providing it. pub files: Vec>, } /// An error which occurred when attempting to record changes. #[allow(missing_docs)] #[derive(Debug, Error)] pub enum RecordError { /// The user cancelled the operation. #[error("cancelled by user")] Cancelled, #[error("failed to set up terminal: {0}")] SetUpTerminal(#[source] io::Error), #[error("failed to clean up terminal: {0}")] CleanUpTerminal(#[source] io::Error), #[error("failed to render new frame: {0}")] RenderFrame(#[source] io::Error), #[error("failed to read user input: {0}")] ReadInput(#[source] io::Error), #[cfg(feature = "serde")] #[error("failed to serialize JSON: {0}")] SerializeJson(#[source] serde_json::Error), #[error("failed to wrote file: {0}")] WriteFile(#[source] io::Error), #[error("{0}")] Other(String), #[error("bug: {0}")] Bug(String), } /// The Unix file mode. The special mode `0` indicates that the file did not exist. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct FileMode(pub usize); impl FileMode { /// Get the file mode corresponding to an absent file. This typically /// indicates that the file was created (if the "before" mode) or deleted /// (if the "after" mode). pub fn absent() -> Self { Self(0) } } impl Display for FileMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let Self(mode) = self; write!(f, "{mode:o}") } } impl From for FileMode { fn from(value: usize) -> Self { Self(value) } } impl From for usize { fn from(value: FileMode) -> Self { let FileMode(value) = value; value } } impl TryFrom for FileMode { type Error = TryFromIntError; fn try_from(value: u32) -> Result { Ok(Self(value.try_into()?)) } } impl TryFrom for u32 { type Error = TryFromIntError; fn try_from(value: FileMode) -> Result { let FileMode(value) = value; value.try_into() } } impl TryFrom for FileMode { type Error = TryFromIntError; fn try_from(value: i32) -> Result { Ok(Self(value.try_into()?)) } } impl TryFrom for i32 { type Error = TryFromIntError; fn try_from(value: FileMode) -> Result { let FileMode(value) = value; value.try_into() } } #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub enum Tristate { False, Partial, True, } impl From for Tristate { fn from(value: bool) -> Self { match value { true => Tristate::True, false => Tristate::False, } } } /// A container of selected changes and commit metadata. #[derive(Clone, Debug, Default, Eq, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct Commit { /// The commit message. If `Some`, then the commit message will be previewed /// in the UI and the user will be able to edit it. If `None`, the commit /// message will not be shown or editable. pub message: Option, } /// The state of a file to be recorded. #[derive(Clone, Debug, Eq, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct File<'a> { /// The path to the previous version of the file, for display purposes. This /// should be set if the file was renamed or copied from another file. pub old_path: Option>, /// The path to the current version of the file, for display purposes. pub path: Cow<'a, Path>, /// The Unix file mode of the file (before any changes), if available. This /// may be rendered by the UI. /// /// This value is not directly modified by the UI; instead, construct a /// [`Section::FileMode`] and use the [`File::get_file_mode`] function /// to read a user-provided updated to the file mode function to read a /// user-provided updated to the file mode. pub file_mode: Option, /// The set of [`Section`]s inside the file. pub sections: Vec>, } /// The contents of a file selected as part of the record operation. #[derive(Clone, Debug, Hash, Eq, PartialEq, PartialOrd, Ord)] pub enum SelectedContents<'a> { /// The file didn't exist or was deleted. Absent, /// The file contents have not changed. Unchanged, /// The file contains binary contents. Binary { /// The UI description of the old version of the file. old_description: Option>, /// The UI description of the new version of the file. new_description: Option>, }, /// The file contained the following text contents. Present { /// The contents of the file. contents: String, }, } impl SelectedContents<'_> { fn push_str(&mut self, s: &str) { match self { SelectedContents::Absent | SelectedContents::Unchanged => { *self = SelectedContents::Present { contents: s.to_owned(), }; } SelectedContents::Binary { old_description: _, new_description: _, } => { // Do nothing. } SelectedContents::Present { contents } => { contents.push_str(s); } } } } impl File<'_> { /// Get the new Unix file mode. If the user selected a /// [`Section::FileMode`], then returns that file mode. Otherwise, returns /// the `file_mode` value that this [`File`] was constructed with. pub fn get_file_mode(&self) -> Option { let Self { old_path: _, path: _, file_mode, sections, } = self; sections .iter() .find_map(|section| match section { Section::Unchanged { .. } | Section::Changed { .. } | Section::FileMode { is_checked: false, before: _, after: _, } | Section::Binary { .. } => None, Section::FileMode { is_checked: true, before: _, after, } => Some(*after), }) .or(*file_mode) } /// Calculate the `(selected, unselected)` contents of the file. For /// example, the first value would be suitable for staging or committing, /// and the second value would be suitable for potentially recording again. pub fn get_selected_contents(&self) -> (SelectedContents, SelectedContents) { let mut acc_selected = SelectedContents::Absent; let mut acc_unselected = SelectedContents::Absent; let Self { old_path: _, path: _, file_mode: _, sections, } = self; for section in sections { match section { Section::Unchanged { lines } => { for line in lines { acc_selected.push_str(line); acc_unselected.push_str(line); } } Section::Changed { lines } => { for line in lines { let SectionChangedLine { is_checked, change_type, line, } = line; match (change_type, is_checked) { (ChangeType::Added, true) | (ChangeType::Removed, false) => { acc_selected.push_str(line); } (ChangeType::Added, false) | (ChangeType::Removed, true) => { acc_unselected.push_str(line); } } } } Section::FileMode { is_checked, before, after, } => { if *is_checked && after == &FileMode::absent() { acc_selected = SelectedContents::Absent; } else if !is_checked && before == &FileMode::absent() { acc_unselected = SelectedContents::Absent; } } Section::Binary { is_checked, old_description, new_description, } => { let selected_contents = SelectedContents::Binary { old_description: old_description.clone(), new_description: new_description.clone(), }; if *is_checked { acc_selected = selected_contents; acc_unselected = SelectedContents::Unchanged; } else { acc_selected = SelectedContents::Unchanged; acc_unselected = selected_contents; } } } } (acc_selected, acc_unselected) } /// Get the tristate value of the file. If there are no sections in this /// file, returns `Tristate::False`. pub fn tristate(&self) -> Tristate { let Self { old_path: _, path: _, file_mode: _, sections, } = self; let mut seen_value = None; for section in sections { match section { Section::Unchanged { .. } => {} Section::Changed { lines } => { for line in lines { seen_value = match (seen_value, line.is_checked) { (None, is_checked) => Some(is_checked), (Some(true), true) => Some(true), (Some(false), false) => Some(false), (Some(true), false) | (Some(false), true) => return Tristate::Partial, }; } } Section::FileMode { is_checked, before: _, after: _, } | Section::Binary { is_checked, old_description: _, new_description: _, } => { seen_value = match (seen_value, is_checked) { (None, is_checked) => Some(*is_checked), (Some(true), true) => Some(true), (Some(false), false) => Some(false), (Some(true), false) | (Some(false), true) => return Tristate::Partial, } } } } match seen_value { Some(true) => Tristate::True, None | Some(false) => Tristate::False, } } /// Set the selection of all sections and lines in this file. pub fn set_checked(&mut self, checked: bool) { let Self { old_path: _, path: _, file_mode: _, sections, } = self; for section in sections { section.set_checked(checked); } } /// Toggle the selection of all sections in this file. pub fn toggle_all(&mut self) { let Self { old_path: _, path: _, file_mode: _, sections, } = self; for section in sections { section.toggle_all(); } } } /// A section of a file to be rendered and recorded. #[derive(Clone, Debug, Eq, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub enum Section<'a> { /// This section of the file is unchanged and just used for context. /// /// By default, only part of the context will be shown. However, all of the /// context lines should be provided so that they can be used to globally /// number the lines correctly. Unchanged { /// The contents of the lines, including their trailing newline /// character(s), if any. lines: Vec>, }, /// This section of the file is changed, and the user needs to select which /// specific changed lines to record. Changed { /// The contents of the lines, including their trailing newline /// character(s), if any. lines: Vec>, }, /// This indicates that the Unix file mode of the file changed, and that the /// user needs to accept that mode change or not. This is not part of the /// "contents" of the file per se, but it's rendered inline as if it were. FileMode { /// Whether or not the file mode change was selected for inclusion in /// the UI. is_checked: bool, /// The old file mode. before: FileMode, /// The new file mode. after: FileMode, }, /// This file contains binary contents. Binary { /// Whether or not the binary contents change was selected for inclusion /// in the UI. is_checked: bool, /// The description of the old binary contents, for use in the UI only. old_description: Option>, /// The description of the new binary contents, for use in the UI only. new_description: Option>, }, } impl Section<'_> { /// Whether or not this section contains user-editable content (as opposed /// to simply contextual content). pub fn is_editable(&self) -> bool { match self { Section::Unchanged { .. } => false, Section::Changed { .. } | Section::FileMode { .. } | Section::Binary { .. } => true, } } /// Get the tristate value of this section. If there are no items in this /// section, returns `Tristate::False`. pub fn tristate(&self) -> Tristate { let mut seen_value = None; match self { Section::Unchanged { .. } => {} Section::Changed { lines } => { for line in lines { seen_value = match (seen_value, line.is_checked) { (None, is_checked) => Some(is_checked), (Some(true), true) => Some(true), (Some(false), false) => Some(false), (Some(true), false) | (Some(false), true) => return Tristate::Partial, }; } } Section::FileMode { is_checked, before: _, after: _, } | Section::Binary { is_checked, old_description: _, new_description: _, } => { seen_value = match (seen_value, is_checked) { (None, is_checked) => Some(*is_checked), (Some(true), true) => Some(true), (Some(false), false) => Some(false), (Some(true), false) | (Some(false), true) => return Tristate::Partial, } } } match seen_value { Some(true) => Tristate::True, None | Some(false) => Tristate::False, } } /// Select or unselect all items in this section. pub fn set_checked(&mut self, checked: bool) { match self { Section::Unchanged { .. } => {} Section::Changed { lines } => { for line in lines { line.is_checked = checked; } } Section::FileMode { is_checked, .. } => { *is_checked = checked; } Section::Binary { is_checked, .. } => { *is_checked = checked; } } } /// Toggle the selection of this section. pub fn toggle_all(&mut self) { match self { Section::Unchanged { .. } => {} Section::Changed { lines } => { for line in lines { line.is_checked = !line.is_checked; } } Section::FileMode { is_checked, .. } => { *is_checked = !*is_checked; } Section::Binary { is_checked, .. } => { *is_checked = !*is_checked; } } } } /// The type of change in the patch/diff. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub enum ChangeType { /// The line was added. Added, /// The line was removed. Removed, } /// A changed line inside a `Section`. #[derive(Clone, Debug, Eq, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct SectionChangedLine<'a> { /// Whether or not this line was selected to be recorded. pub is_checked: bool, /// The type of change this line was. pub change_type: ChangeType, /// The contents of the line, including its trailing newline character(s), /// if any. pub line: Cow<'a, str>, } scm-record-0.5.0/src/ui.rs000064400000000000000000003741751046102023000134570ustar 00000000000000//! UI implementation. use std::any::Any; use std::borrow::Cow; use std::cell::RefCell; use std::cmp::min; use std::collections::{BTreeMap, HashSet}; use std::fmt::Write; use std::fmt::{Debug, Display}; use std::hash::Hash; use std::path::Path; use std::rc::Rc; use std::{io, iter, mem, panic}; use crossterm::event::{ DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, is_raw_mode_enabled, EnterAlternateScreen, LeaveAlternateScreen, }; use ratatui::backend::{Backend, TestBackend}; use ratatui::buffer::Buffer; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::{backend::CrosstermBackend, Terminal}; use tracing::warn; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::consts::ENV_VAR_DEBUG_UI; use crate::render::{ centered_rect, Component, DrawnRect, DrawnRects, Mask, Rect, RectSize, Viewport, }; use crate::types::{ChangeType, Commit, RecordError, RecordState, Tristate}; use crate::util::{IsizeExt, UsizeExt}; use crate::{File, Section, SectionChangedLine}; const NUM_CONTEXT_LINES: usize = 3; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] struct FileKey { commit_idx: usize, file_idx: usize, } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] struct SectionKey { commit_idx: usize, file_idx: usize, section_idx: usize, } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] struct LineKey { commit_idx: usize, file_idx: usize, section_idx: usize, line_idx: usize, } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] enum QuitDialogButtonId { Quit, GoBack, } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] enum SelectionKey { None, File(FileKey), Section(SectionKey), Line(LineKey), } impl Default for SelectionKey { fn default() -> Self { Self::None } } /// A copy of the contents of the screen at a certain point in time. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct TestingScreenshot { contents: Rc>>, } impl TestingScreenshot { fn set(&self, new_contents: String) { let Self { contents } = self; *contents.borrow_mut() = Some(new_contents); } /// Produce an `Event` which will record the screenshot when it's handled. pub fn event(&self) -> Event { Event::TakeScreenshot(self.clone()) } } impl Display for TestingScreenshot { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let Self { contents } = self; match contents.borrow().as_ref() { Some(contents) => write!(f, "{contents}"), None => write!(f, ""), } } } #[allow(missing_docs)] #[derive(Clone, Debug, Eq, PartialEq)] pub enum Event { None, QuitAccept, QuitCancel, QuitInterrupt, QuitEscape, TakeScreenshot(TestingScreenshot), Redraw, EnsureSelectionInViewport, ScrollUp, ScrollDown, PageUp, PageDown, FocusPrev, /// Move focus to the previous item of the same kind (i.e. file, section, line). FocusPrevSameKind, FocusPrevPage, FocusNext, /// Move focus to the next item of the same kind. FocusNextSameKind, FocusNextPage, FocusInner, /// If `fold_section` is true, and the current section is expanded, the /// section should be collapsed without moving focus. Otherwise, move the /// focus outwards. FocusOuter { fold_section: bool, }, ToggleItem, ToggleItemAndAdvance, ToggleAll, ToggleAllUniform, ExpandItem, ExpandAll, Click { row: usize, column: usize, }, ToggleCommitViewMode, // no key binding currently EditCommitMessage, Help, } impl From for Event { fn from(event: crossterm::event::Event) -> Self { use crossterm::event::Event; match event { Event::Key(KeyEvent { code: KeyCode::Char('q'), modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, }) => Self::QuitCancel, Event::Key(KeyEvent { code: KeyCode::Esc, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, }) => Self::QuitEscape, Event::Key(KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, kind: KeyEventKind::Press, state: _, }) => Self::QuitInterrupt, Event::Key(KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, }) => Self::QuitAccept, Event::Key(KeyEvent { code: KeyCode::Char('?'), modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, }) => Self::Help, Event::Key(KeyEvent { code: KeyCode::Up | KeyCode::Char('y'), modifiers: KeyModifiers::CONTROL, kind: KeyEventKind::Press, state: _, }) | Event::Mouse(MouseEvent { kind: MouseEventKind::ScrollUp, column: _, row: _, modifiers: _, }) => Self::ScrollUp, Event::Key(KeyEvent { code: KeyCode::Down | KeyCode::Char('e'), modifiers: KeyModifiers::CONTROL, kind: KeyEventKind::Press, state: _, }) | Event::Mouse(MouseEvent { kind: MouseEventKind::ScrollDown, column: _, row: _, modifiers: _, }) => Self::ScrollDown, Event::Key(KeyEvent { code: KeyCode::PageUp | KeyCode::Char('b'), modifiers: KeyModifiers::CONTROL, kind: KeyEventKind::Press, state: _, }) => Self::PageUp, Event::Key(KeyEvent { code: KeyCode::PageDown | KeyCode::Char('f'), modifiers: KeyModifiers::CONTROL, kind: KeyEventKind::Press, state: _, }) => Self::PageDown, Event::Key(KeyEvent { code: KeyCode::Up | KeyCode::Char('k'), modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, }) => Self::FocusPrev, Event::Key(KeyEvent { code: KeyCode::Down | KeyCode::Char('j'), modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, }) => Self::FocusNext, Event::Key(KeyEvent { code: KeyCode::PageUp, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, }) => Self::FocusPrevSameKind, Event::Key(KeyEvent { code: KeyCode::PageDown, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, }) => Self::FocusNextSameKind, Event::Key(KeyEvent { code: KeyCode::Left | KeyCode::Char('h'), modifiers: KeyModifiers::SHIFT, kind: KeyEventKind::Press, state: _, }) => Self::FocusOuter { fold_section: false, }, Event::Key(KeyEvent { code: KeyCode::Left | KeyCode::Char('h'), modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, }) => Self::FocusOuter { fold_section: true }, Event::Key(KeyEvent { code: KeyCode::Right | KeyCode::Char('l'), // The shift modifier is accepted for continuity with FocusOuter. modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, kind: KeyEventKind::Press, state: _, }) => Self::FocusInner, Event::Key(KeyEvent { code: KeyCode::Char('u'), modifiers: KeyModifiers::CONTROL, kind: KeyEventKind::Press, state: _, }) => Self::FocusPrevPage, Event::Key(KeyEvent { code: KeyCode::Char('d'), modifiers: KeyModifiers::CONTROL, kind: KeyEventKind::Press, state: _, }) => Self::FocusNextPage, Event::Key(KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, }) => Self::ToggleItem, Event::Key(KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, }) => Self::ToggleItemAndAdvance, Event::Key(KeyEvent { code: KeyCode::Char('a'), modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, }) => Self::ToggleAll, Event::Key(KeyEvent { code: KeyCode::Char('A'), modifiers: KeyModifiers::SHIFT, kind: KeyEventKind::Press, state: _, }) => Self::ToggleAllUniform, Event::Key(KeyEvent { code: KeyCode::Char('f'), modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _, }) => Self::ExpandItem, Event::Key(KeyEvent { code: KeyCode::Char('F'), modifiers: KeyModifiers::SHIFT, kind: KeyEventKind::Press, state: _, }) => Self::ExpandAll, Event::Key(KeyEvent { code: KeyCode::Char('e'), modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: _event, }) => Self::EditCommitMessage, Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column, row, modifiers: _, }) => Self::Click { row: row.into(), column: column.into(), }, _event => Self::None, } } } /// The terminal backend to use. pub enum TerminalKind { /// Use the `CrosstermBackend` backend. Crossterm, /// Use the `TestingBackend` backend. Testing { /// The width of the virtual terminal. width: usize, /// The height of the virtual terminal. height: usize, }, } /// Get user input. pub trait RecordInput { /// Return the kind of terminal to use. fn terminal_kind(&self) -> TerminalKind; /// Get all available user events. This should block until there is at least /// one available event. fn next_events(&mut self) -> Result, RecordError>; /// Open a commit editor and interactively edit the given message. /// /// This function will only be invoked if one of the provided `Commit`s had /// a non-`None` commit message. fn edit_commit_message(&mut self, message: &str) -> Result; } /// Copied from internal implementation of `tui`. fn buffer_view(buffer: &Buffer) -> String { let mut view = String::with_capacity(buffer.content.len() + usize::from(buffer.area.height) * 3); for cells in buffer.content.chunks(buffer.area.width.into()) { let mut overwritten = vec![]; let mut skip: usize = 0; view.push('"'); for (x, c) in cells.iter().enumerate() { if skip == 0 { view.push_str(c.symbol()); } else { overwritten.push((x, c.symbol())) } skip = std::cmp::max(skip, c.symbol().width()).saturating_sub(1); } view.push('"'); if !overwritten.is_empty() { write!( &mut view, " Hidden by multi-width symbols: {:?}", overwritten ) .unwrap(); } view.push('\n'); } view } #[derive(Clone, Debug, PartialEq, Eq)] enum StateUpdate { None, SetQuitDialog(Option), QuitAccept, QuitCancel, SetHelpDialog(Option), TakeScreenshot(TestingScreenshot), Redraw, EnsureSelectionInViewport, ScrollTo(isize), SelectItem { selection_key: SelectionKey, ensure_in_viewport: bool, }, ToggleItem(SelectionKey), ToggleItemAndAdvance(SelectionKey, SelectionKey), ToggleAll, ToggleAllUniform, SetExpandItem(SelectionKey, bool), ToggleExpandItem(SelectionKey), ToggleExpandAll, UnfocusMenuBar, ClickMenu { menu_idx: usize, }, ClickMenuItem(Event), ToggleCommitViewMode, EditCommitMessage { commit_idx: usize, }, } #[derive(Clone, Copy, Debug)] enum CommitViewMode { Inline, Adjacent, } /// UI component to record the user's changes. pub struct Recorder<'state, 'input> { state: RecordState<'state>, input: &'input mut dyn RecordInput, pending_events: Vec, use_unicode: bool, commit_view_mode: CommitViewMode, expanded_items: HashSet, expanded_menu_idx: Option, selection_key: SelectionKey, focused_commit_idx: usize, quit_dialog: Option, help_dialog: Option, scroll_offset_y: isize, } impl<'state, 'input> Recorder<'state, 'input> { /// Constructor. pub fn new(mut state: RecordState<'state>, input: &'input mut dyn RecordInput) -> Self { // Ensure that there are at least two commits. state.commits.extend( iter::repeat_with(Commit::default).take(2_usize.saturating_sub(state.commits.len())), ); if state.commits.len() > 2 { unimplemented!("more than two commits"); } let mut recorder = Self { state, input, pending_events: Default::default(), use_unicode: true, commit_view_mode: CommitViewMode::Inline, expanded_items: Default::default(), expanded_menu_idx: Default::default(), selection_key: SelectionKey::None, focused_commit_idx: 0, quit_dialog: None, help_dialog: None, scroll_offset_y: 0, }; recorder.expand_initial_items(); recorder } /// Run the terminal user interface and have the user interactively select /// changes. pub fn run(self) -> Result, RecordError> { #[cfg(feature = "debug")] if std::env::var_os(crate::consts::ENV_VAR_DUMP_UI_STATE).is_some() { let ui_state = serde_json::to_string_pretty(&self.state).map_err(RecordError::SerializeJson)?; std::fs::write(crate::consts::DUMP_UI_STATE_FILENAME, ui_state) .map_err(RecordError::WriteFile)?; } match self.input.terminal_kind() { TerminalKind::Crossterm => self.run_crossterm(), TerminalKind::Testing { width, height } => self.run_testing(width, height), } } /// Run the recorder UI using `crossterm` as the backend connected to stdout. fn run_crossterm(self) -> Result, RecordError> { Self::set_up_crossterm()?; Self::install_panic_hook(); let backend = CrosstermBackend::new(io::stdout()); let mut term = Terminal::new(backend).map_err(RecordError::SetUpTerminal)?; term.clear().map_err(RecordError::RenderFrame)?; let result = self.run_inner(&mut term); Self::clean_up_crossterm()?; result } fn install_panic_hook() { // HACK: installing a global hook here. This could be installed multiple // times, and there's no way to uninstall it once we return. // // The idea is // taken from // https://github.com/fdehau/tui-rs/blob/fafad6c96109610825aad89c4bba5253e01101ed/examples/panic.rs. // // For some reason, simply catching the panic, cleaning up, and // reraising the panic loses information about where the panic was // originally raised, which is frustrating. let original_hook = panic::take_hook(); panic::set_hook(Box::new(move |panic| { Self::clean_up_crossterm().unwrap(); original_hook(panic); })); } fn set_up_crossterm() -> Result<(), RecordError> { if !is_raw_mode_enabled().map_err(RecordError::SetUpTerminal)? { crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture) .map_err(RecordError::SetUpTerminal)?; enable_raw_mode().map_err(RecordError::SetUpTerminal)?; } Ok(()) } fn clean_up_crossterm() -> Result<(), RecordError> { if is_raw_mode_enabled().map_err(RecordError::CleanUpTerminal)? { disable_raw_mode().map_err(RecordError::CleanUpTerminal)?; crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture) .map_err(RecordError::CleanUpTerminal)?; } Ok(()) } fn run_testing(self, width: usize, height: usize) -> Result, RecordError> { let backend = TestBackend::new(width.clamp_into_u16(), height.clamp_into_u16()); let mut term = Terminal::new(backend).map_err(RecordError::SetUpTerminal)?; self.run_inner(&mut term) } fn run_inner( mut self, term: &mut Terminal, ) -> Result, RecordError> { self.selection_key = self.first_selection_key(); let debug = if cfg!(feature = "debug") { std::env::var_os(ENV_VAR_DEBUG_UI).is_some() } else { false }; 'outer: loop { let menu_bar = self.make_menu_bar(); let app = self.make_app(menu_bar.clone(), None); let term_height = usize::from(term.get_frame().area().height); let mut drawn_rects: Option> = None; term.draw(|frame| { drawn_rects = Some(Viewport::::render_top_level( frame, 0, self.scroll_offset_y, &app, )); }) .map_err(RecordError::RenderFrame)?; let drawn_rects = drawn_rects.unwrap(); // Dump debug info. We may need to use information about the // rendered app, so we perform a re-render here. if debug { let debug_info = AppDebugInfo { term_height, scroll_offset_y: self.scroll_offset_y, selection_key: self.selection_key, selection_key_y: self.selection_key_y(&drawn_rects, self.selection_key), drawn_rects: drawn_rects.clone().into_iter().collect(), }; let debug_app = AppView { debug_info: Some(debug_info), ..app.clone() }; term.draw(|frame| { Viewport::::render_top_level( frame, 0, self.scroll_offset_y, &debug_app, ); }) .map_err(RecordError::RenderFrame)?; } let events = if self.pending_events.is_empty() { self.input.next_events()? } else { // FIXME: the pending events should be applied without redrawing // the screen, as otherwise there may be a flash of content // containing the screen contents before the event is applied. mem::take(&mut self.pending_events) }; for event in events { match self.handle_event(event, term_height, &drawn_rects, &menu_bar)? { StateUpdate::None => {} StateUpdate::SetQuitDialog(quit_dialog) => { self.quit_dialog = quit_dialog; } StateUpdate::SetHelpDialog(help_dialog) => { self.help_dialog = help_dialog; } StateUpdate::QuitAccept => { if self.help_dialog.is_some() { self.help_dialog = None; } else { break 'outer; } } StateUpdate::QuitCancel => return Err(RecordError::Cancelled), StateUpdate::TakeScreenshot(screenshot) => { let backend: &dyn Any = term.backend(); let test_backend = backend .downcast_ref::() .expect("TakeScreenshot event generated for non-testing backend"); screenshot.set(buffer_view(test_backend.buffer())); } StateUpdate::Redraw => { term.clear().map_err(RecordError::RenderFrame)?; } StateUpdate::EnsureSelectionInViewport => { if let Some(scroll_offset_y) = self.ensure_in_viewport(term_height, &drawn_rects, self.selection_key) { self.scroll_offset_y = scroll_offset_y; } } StateUpdate::ScrollTo(scroll_offset_y) => { self.scroll_offset_y = scroll_offset_y.clamp(0, { let DrawnRect { rect, timestamp: _ } = drawn_rects[&ComponentId::App]; rect.height.unwrap_isize() - 1 }); } StateUpdate::SelectItem { selection_key, ensure_in_viewport, } => { self.selection_key = selection_key; self.expand_item_ancestors(selection_key); if ensure_in_viewport { self.pending_events.push(Event::EnsureSelectionInViewport); } } StateUpdate::ToggleItem(selection_key) => { self.toggle_item(selection_key)?; } StateUpdate::ToggleItemAndAdvance(selection_key, new_key) => { self.toggle_item(selection_key)?; self.selection_key = new_key; self.pending_events.push(Event::EnsureSelectionInViewport); } StateUpdate::ToggleAll => { self.toggle_all(); } StateUpdate::ToggleAllUniform => { self.toggle_all_uniform(); } StateUpdate::SetExpandItem(selection_key, is_expanded) => { self.set_expand_item(selection_key, is_expanded); self.pending_events.push(Event::EnsureSelectionInViewport); } StateUpdate::ToggleExpandItem(selection_key) => { self.toggle_expand_item(selection_key)?; self.pending_events.push(Event::EnsureSelectionInViewport); } StateUpdate::ToggleExpandAll => { self.toggle_expand_all()?; self.pending_events.push(Event::EnsureSelectionInViewport); } StateUpdate::UnfocusMenuBar => { self.unfocus_menu_bar(); } StateUpdate::ClickMenu { menu_idx } => { self.click_menu_header(menu_idx); } StateUpdate::ClickMenuItem(event) => { self.click_menu_item(event); } StateUpdate::ToggleCommitViewMode => { self.commit_view_mode = match self.commit_view_mode { CommitViewMode::Inline => CommitViewMode::Adjacent, CommitViewMode::Adjacent => CommitViewMode::Inline, }; } StateUpdate::EditCommitMessage { commit_idx } => { self.pending_events.push(Event::Redraw); self.edit_commit_message(commit_idx)?; } } } } Ok(self.state) } fn make_menu_bar(&self) -> MenuBar<'static> { MenuBar { menus: vec![ Menu { label: Cow::Borrowed("File"), items: vec![ MenuItem { label: Cow::Borrowed("Confirm (c)"), event: Event::QuitAccept, }, MenuItem { label: Cow::Borrowed("Quit (q)"), event: Event::QuitCancel, }, ], }, Menu { label: Cow::Borrowed("Edit"), items: vec![ MenuItem { label: Cow::Borrowed("Edit message (e)"), event: Event::EditCommitMessage, }, MenuItem { label: Cow::Borrowed("Toggle current (space)"), event: Event::ToggleItem, }, MenuItem { label: Cow::Borrowed("Toggle current and advance (enter)"), event: Event::ToggleItemAndAdvance, }, MenuItem { label: Cow::Borrowed("Invert all items (a)"), event: Event::ToggleAll, }, MenuItem { label: Cow::Borrowed("Invert all items uniformly (A)"), event: Event::ToggleAllUniform, }, ], }, Menu { label: Cow::Borrowed("Select"), items: vec![ MenuItem { label: Cow::Borrowed("Previous item (up, k)"), event: Event::FocusPrev, }, MenuItem { label: Cow::Borrowed("Next item (down, j)"), event: Event::FocusNext, }, MenuItem { label: Cow::Borrowed("Previous item of the same kind (page-up)"), event: Event::FocusPrevSameKind, }, MenuItem { label: Cow::Borrowed("Next item of the same kind (page-down)"), event: Event::FocusNextSameKind, }, MenuItem { label: Cow::Borrowed( "Outer item without folding (shift-left, shift-h)", ), event: Event::FocusOuter { fold_section: false, }, }, MenuItem { label: Cow::Borrowed("Outer item with folding (left, h)"), event: Event::FocusOuter { fold_section: true }, }, MenuItem { label: Cow::Borrowed("Inner item with unfolding (right, l)"), event: Event::FocusInner, }, MenuItem { label: Cow::Borrowed("Previous page (ctrl-u)"), event: Event::FocusPrevPage, }, MenuItem { label: Cow::Borrowed("Next page (ctrl-d)"), event: Event::FocusNextPage, }, ], }, Menu { label: Cow::Borrowed("View"), items: vec![ MenuItem { label: Cow::Borrowed("Fold/unfold current (f)"), event: Event::ExpandItem, }, MenuItem { label: Cow::Borrowed("Fold/unfold all (F)"), event: Event::ExpandAll, }, MenuItem { label: Cow::Borrowed("Scroll up (ctrl-up, ctrl-y)"), event: Event::ScrollUp, }, MenuItem { label: Cow::Borrowed("Scroll down (ctrl-down, ctrl-e)"), event: Event::ScrollDown, }, MenuItem { label: Cow::Borrowed("Previous page (ctrl-page-up, ctrl-b)"), event: Event::PageUp, }, MenuItem { label: Cow::Borrowed("Next page (ctrl-page-down, ctrl-f)"), event: Event::PageDown, }, ], }, ], expanded_menu_idx: self.expanded_menu_idx, } } fn make_app( &'state self, menu_bar: MenuBar<'static>, debug_info: Option, ) -> AppView<'state> { let RecordState { is_read_only, commits, files, } = &self.state; let commit_views = match self.commit_view_mode { CommitViewMode::Inline => { vec![CommitView { debug_info: None, commit_message_view: CommitMessageView { commit_idx: self.focused_commit_idx, commit: &commits[self.focused_commit_idx], }, file_views: self.make_file_views( self.focused_commit_idx, files, &debug_info, *is_read_only, ), }] } CommitViewMode::Adjacent => commits .iter() .enumerate() .map(|(commit_idx, commit)| CommitView { debug_info: None, commit_message_view: CommitMessageView { commit_idx, commit }, file_views: self.make_file_views(commit_idx, files, &debug_info, *is_read_only), }) .collect(), }; AppView { debug_info: None, menu_bar, commit_view_mode: self.commit_view_mode, commit_views, quit_dialog: self.quit_dialog.clone(), help_dialog: self.help_dialog.clone(), } } fn make_file_views( &'state self, commit_idx: usize, files: &'state [File<'state>], debug_info: &Option, is_read_only: bool, ) -> Vec> { files .iter() .enumerate() .map(|(file_idx, file)| { let file_key = FileKey { commit_idx, file_idx, }; let file_toggled = self.file_tristate(file_key).unwrap(); let file_expanded = self.file_expanded(file_key); let is_focused = match self.selection_key { SelectionKey::None | SelectionKey::Section(_) | SelectionKey::Line(_) => false, SelectionKey::File(selected_file_key) => file_key == selected_file_key, }; FileView { debug: debug_info.is_some(), file_key, toggle_box: TristateBox { use_unicode: self.use_unicode, id: ComponentId::ToggleBox(SelectionKey::File(file_key)), icon_style: TristateIconStyle::Check, tristate: file_toggled, is_focused, is_read_only, }, expand_box: TristateBox { use_unicode: self.use_unicode, id: ComponentId::ExpandBox(SelectionKey::File(file_key)), icon_style: TristateIconStyle::Expand, tristate: file_expanded, is_focused, is_read_only: false, }, is_header_selected: is_focused, old_path: file.old_path.as_deref(), path: &file.path, section_views: { let mut section_views = Vec::new(); let total_num_sections = file.sections.len(); let total_num_editable_sections = file .sections .iter() .filter(|section| section.is_editable()) .count(); let mut line_num = 1; let mut editable_section_num = 0; for (section_idx, section) in file.sections.iter().enumerate() { let section_key = SectionKey { commit_idx, file_idx, section_idx, }; let section_toggled = self.section_tristate(section_key).unwrap(); let section_expanded = Tristate::from( self.expanded_items .contains(&SelectionKey::Section(section_key)), ); let is_focused = match self.selection_key { SelectionKey::None | SelectionKey::File(_) | SelectionKey::Line(_) => false, SelectionKey::Section(selection_section_key) => { selection_section_key == section_key } }; if section.is_editable() { editable_section_num += 1; } section_views.push(SectionView { use_unicode: self.use_unicode, is_read_only, section_key, toggle_box: TristateBox { use_unicode: self.use_unicode, is_read_only, id: ComponentId::ToggleBox(SelectionKey::Section(section_key)), tristate: section_toggled, icon_style: TristateIconStyle::Check, is_focused, }, expand_box: TristateBox { use_unicode: self.use_unicode, is_read_only: false, id: ComponentId::ExpandBox(SelectionKey::Section(section_key)), tristate: section_expanded, icon_style: TristateIconStyle::Expand, is_focused, }, selection: match self.selection_key { SelectionKey::None | SelectionKey::File(_) => None, SelectionKey::Section(selected_section_key) => { if selected_section_key == section_key { Some(SectionSelection::SectionHeader) } else { None } } SelectionKey::Line(LineKey { commit_idx, file_idx, section_idx, line_idx, }) => { let selected_section_key = SectionKey { commit_idx, file_idx, section_idx, }; if selected_section_key == section_key { Some(SectionSelection::ChangedLine(line_idx)) } else { None } } }, total_num_sections, editable_section_num, total_num_editable_sections, section, line_start_num: line_num, }); line_num += match section { Section::Unchanged { lines } => lines.len(), Section::Changed { lines } => lines .iter() .filter(|changed_line| match changed_line.change_type { ChangeType::Added => false, ChangeType::Removed => true, }) .count(), Section::FileMode { .. } | Section::Binary { .. } => 0, }; } section_views }, } }) .collect() } fn handle_event( &self, event: Event, term_height: usize, drawn_rects: &DrawnRects, menu_bar: &MenuBar, ) -> Result { let state_update = match (&self.quit_dialog, event) { (_, Event::None) => StateUpdate::None, (_, Event::Redraw) => StateUpdate::Redraw, (_, Event::EnsureSelectionInViewport) => StateUpdate::EnsureSelectionInViewport, ( _, Event::Help | Event::QuitEscape | Event::QuitCancel | Event::ToggleItem | Event::ToggleItemAndAdvance, ) if self.help_dialog.is_some() => { // there is only one button in the help dialog, so 'toggle*' means "click close" StateUpdate::SetHelpDialog(None) } (_, Event::Help) => StateUpdate::SetHelpDialog(Some(HelpDialog())), // Confirm the changes. (None, Event::QuitAccept) => StateUpdate::QuitAccept, // Ignore the confirm action if the quit dialog is open. (Some(_), Event::QuitAccept) => StateUpdate::None, // Render quit dialog if the user made changes. (None, Event::QuitCancel | Event::QuitInterrupt) => { let num_commit_messages = self.num_user_commit_messages()?; let num_changed_files = self.num_user_file_changes()?; if num_commit_messages > 0 || num_changed_files > 0 { StateUpdate::SetQuitDialog(Some(QuitDialog { num_commit_messages, num_changed_files, focused_button: QuitDialogButtonId::Quit, })) } else { StateUpdate::QuitCancel } } // If pressing quit again, or escape, while the dialog is open, close it. (Some(_), Event::QuitCancel | Event::QuitEscape) => StateUpdate::SetQuitDialog(None), // If pressing ctrl-c again wile the dialog is open, force quit. (Some(_), Event::QuitInterrupt) => StateUpdate::QuitCancel, // Select left quit dialog button. (Some(quit_dialog), Event::FocusOuter { .. }) => { StateUpdate::SetQuitDialog(Some(QuitDialog { focused_button: QuitDialogButtonId::GoBack, ..quit_dialog.clone() })) } // Select right quit dialog button. (Some(quit_dialog), Event::FocusInner) => { StateUpdate::SetQuitDialog(Some(QuitDialog { focused_button: QuitDialogButtonId::Quit, ..quit_dialog.clone() })) } // Press the appropriate dialog button. (Some(quit_dialog), Event::ToggleItem | Event::ToggleItemAndAdvance) => { let QuitDialog { num_commit_messages: _, num_changed_files: _, focused_button, } = quit_dialog; match focused_button { QuitDialogButtonId::Quit => StateUpdate::QuitCancel, QuitDialogButtonId::GoBack => StateUpdate::SetQuitDialog(None), } } // Disable most keyboard shortcuts while the quit dialog is open. ( Some(_), Event::ScrollUp | Event::ScrollDown | Event::PageUp | Event::PageDown | Event::FocusPrev | Event::FocusNext | Event::FocusPrevSameKind | Event::FocusNextSameKind | Event::FocusPrevPage | Event::FocusNextPage | Event::ToggleAll | Event::ToggleAllUniform | Event::ExpandItem | Event::ExpandAll | Event::EditCommitMessage, ) => StateUpdate::None, (Some(_) | None, Event::TakeScreenshot(screenshot)) => { StateUpdate::TakeScreenshot(screenshot) } (None, Event::ScrollUp) => { StateUpdate::ScrollTo(self.scroll_offset_y.saturating_sub(1)) } (None, Event::ScrollDown) => { StateUpdate::ScrollTo(self.scroll_offset_y.saturating_add(1)) } (None, Event::PageUp) => StateUpdate::ScrollTo( self.scroll_offset_y .saturating_sub(term_height.unwrap_isize()), ), (None, Event::PageDown) => StateUpdate::ScrollTo( self.scroll_offset_y .saturating_add(term_height.unwrap_isize()), ), (None, Event::FocusPrev) => { let (keys, index) = self.find_selection(); let selection_key = self.select_prev(&keys, index); StateUpdate::SelectItem { selection_key, ensure_in_viewport: true, } } (None, Event::FocusNext) => { let (keys, index) = self.find_selection(); let selection_key = self.select_next(&keys, index); StateUpdate::SelectItem { selection_key, ensure_in_viewport: true, } } (None, Event::FocusPrevSameKind) => { let selection_key = self.select_prev_or_next_of_same_kind(/*select_previous=*/ true); StateUpdate::SelectItem { selection_key, ensure_in_viewport: true, } } (None, Event::FocusNextSameKind) => { let selection_key = self.select_prev_or_next_of_same_kind(/*select_previous=*/ false); StateUpdate::SelectItem { selection_key, ensure_in_viewport: true, } } (None, Event::FocusPrevPage) => { let selection_key = self.select_prev_page(term_height, drawn_rects); StateUpdate::SelectItem { selection_key, ensure_in_viewport: true, } } (None, Event::FocusNextPage) => { let selection_key = self.select_next_page(term_height, drawn_rects); StateUpdate::SelectItem { selection_key, ensure_in_viewport: true, } } (None, Event::FocusOuter { fold_section }) => self.select_outer(fold_section), (None, Event::FocusInner) => { let selection_key = self.select_inner(); StateUpdate::SelectItem { selection_key, ensure_in_viewport: true, } } (None, Event::ToggleItem) => StateUpdate::ToggleItem(self.selection_key), (None, Event::ToggleItemAndAdvance) => { let advanced_key = self.advance_to_next_of_kind(); StateUpdate::ToggleItemAndAdvance(self.selection_key, advanced_key) } (None, Event::ToggleAll) => StateUpdate::ToggleAll, (None, Event::ToggleAllUniform) => StateUpdate::ToggleAllUniform, (None, Event::ExpandItem) => StateUpdate::ToggleExpandItem(self.selection_key), (None, Event::ExpandAll) => StateUpdate::ToggleExpandAll, (None, Event::EditCommitMessage) => StateUpdate::EditCommitMessage { commit_idx: self.focused_commit_idx, }, (_, Event::Click { row, column }) => { let component_id = self.find_component_at(drawn_rects, row, column); self.click_component(menu_bar, component_id) } (_, Event::ToggleCommitViewMode) => StateUpdate::ToggleCommitViewMode, // generally ignore escape key (_, Event::QuitEscape) => StateUpdate::None, }; Ok(state_update) } fn first_selection_key(&self) -> SelectionKey { match self.state.files.iter().enumerate().next() { Some((file_idx, _)) => SelectionKey::File(FileKey { commit_idx: self.focused_commit_idx, file_idx, }), None => SelectionKey::None, } } fn num_user_commit_messages(&self) -> Result { let RecordState { files: _, commits, is_read_only: _, } = &self.state; Ok(commits .iter() .map(|commit| { let Commit { message } = commit; match message { Some(message) if !message.is_empty() => 1, _ => 0, } }) .sum()) } fn num_user_file_changes(&self) -> Result { let RecordState { files, commits: _, is_read_only: _, } = &self.state; let mut result = 0; for (file_idx, _file) in files.iter().enumerate() { match self.file_tristate(FileKey { commit_idx: self.focused_commit_idx, file_idx, })? { Tristate::False => {} Tristate::Partial | Tristate::True => { result += 1; } } } Ok(result) } fn all_selection_keys(&self) -> Vec { let mut result = Vec::new(); for (commit_idx, _) in self.state.commits.iter().enumerate() { if commit_idx > 0 { // TODO: implement adjacent `CommitView s. continue; } for (file_idx, file) in self.state.files.iter().enumerate() { result.push(SelectionKey::File(FileKey { commit_idx, file_idx, })); for (section_idx, section) in file.sections.iter().enumerate() { match section { Section::Unchanged { .. } => {} Section::Changed { lines } => { result.push(SelectionKey::Section(SectionKey { commit_idx, file_idx, section_idx, })); for (line_idx, _line) in lines.iter().enumerate() { result.push(SelectionKey::Line(LineKey { commit_idx, file_idx, section_idx, line_idx, })); } } Section::FileMode { is_checked: _, before: _, after: _, } | Section::Binary { .. } => { result.push(SelectionKey::Section(SectionKey { commit_idx, file_idx, section_idx, })); } } } } } result } fn find_selection(&self) -> (Vec, Option) { // FIXME: finding the selected key is an O(n) algorithm (instead of O(log(n)) or O(1)). let visible_keys: Vec<_> = self .all_selection_keys() .iter() .cloned() .filter(|key| match key { SelectionKey::None => false, SelectionKey::File(_) => true, SelectionKey::Section(section_key) => { let file_key = FileKey { commit_idx: section_key.commit_idx, file_idx: section_key.file_idx, }; match self.file_expanded(file_key) { Tristate::False => false, Tristate::Partial | Tristate::True => true, } } SelectionKey::Line(line_key) => { let file_key = FileKey { commit_idx: line_key.commit_idx, file_idx: line_key.file_idx, }; let section_key = SectionKey { commit_idx: line_key.commit_idx, file_idx: line_key.file_idx, section_idx: line_key.section_idx, }; self.expanded_items.contains(&SelectionKey::File(file_key)) && self .expanded_items .contains(&SelectionKey::Section(section_key)) } }) .collect(); let index = visible_keys.iter().enumerate().find_map(|(k, v)| { if v == &self.selection_key { Some(k) } else { None } }); (visible_keys, index) } fn select_prev(&self, keys: &[SelectionKey], index: Option) -> SelectionKey { match index { None => self.first_selection_key(), Some(index) => match index.checked_sub(1) { Some(index) => keys[index], None => { // TODO: this behavior will be wrong if we have keys for each `Commit` (which currently isn't the case). *keys.last().unwrap() } }, } } fn select_next(&self, keys: &[SelectionKey], index: Option) -> SelectionKey { match index { None => self.first_selection_key(), Some(index) => match keys.get(index + 1) { Some(key) => *key, None => keys[0], }, } } // Returns the previous or next SelectionKey of the same kind as the current // selection key. If there are no other keys of the same kind, the current // key is returned instead. If `select_previous` is true, the previous key // is returned. Otherwise, the next key is returned. fn select_prev_or_next_of_same_kind(&self, select_previous: bool) -> SelectionKey { let (keys, index) = self.find_selection(); let iterate_keys_with_wrap_around = |i| -> Box> { let forward_iter = keys[i + 1..] // Skip the current key .iter() .chain(keys[..i].iter()); if select_previous { Box::new(forward_iter.rev()) } else { Box::new(forward_iter) } }; match index { None => self.first_selection_key(), Some(index) => { match iterate_keys_with_wrap_around(index) .find(|k| std::mem::discriminant(*k) == std::mem::discriminant(&keys[index])) { None => keys[index], Some(key) => *key, } } } } fn select_prev_page( &self, term_height: usize, drawn_rects: &DrawnRects, ) -> SelectionKey { let (keys, index) = self.find_selection(); let mut index = match index { Some(index) => index, None => return SelectionKey::None, }; let original_y = match self.selection_key_y(drawn_rects, self.selection_key) { Some(original_y) => original_y, None => { return SelectionKey::None; } }; let target_y = original_y.saturating_sub(term_height.unwrap_isize() / 2); while index > 0 { index -= 1; let selection_key_y = self.selection_key_y(drawn_rects, keys[index]); if let Some(selection_key_y) = selection_key_y { if selection_key_y <= target_y { break; } } } keys[index] } fn select_next_page( &self, term_height: usize, drawn_rects: &DrawnRects, ) -> SelectionKey { let (keys, index) = self.find_selection(); let mut index = match index { Some(index) => index, None => return SelectionKey::None, }; let original_y = match self.selection_key_y(drawn_rects, self.selection_key) { Some(original_y) => original_y, None => return SelectionKey::None, }; let target_y = original_y.saturating_add(term_height.unwrap_isize() / 2); while index + 1 < keys.len() { index += 1; let selection_key_y = self.selection_key_y(drawn_rects, keys[index]); if let Some(selection_key_y) = selection_key_y { if selection_key_y >= target_y { break; } } } keys[index] } fn select_inner(&self) -> SelectionKey { self.all_selection_keys() .into_iter() .skip_while(|selection_key| selection_key != &self.selection_key) .skip(1) .find(|selection_key| { match (self.selection_key, selection_key) { (SelectionKey::None, _) => true, (_, SelectionKey::None) => false, // shouldn't happen (SelectionKey::File(_), SelectionKey::File(_)) => false, (SelectionKey::File(_), SelectionKey::Section(_)) => true, (SelectionKey::File(_), SelectionKey::Line(_)) => false, // shouldn't happen (SelectionKey::Section(_), SelectionKey::File(_)) | (SelectionKey::Section(_), SelectionKey::Section(_)) => false, (SelectionKey::Section(_), SelectionKey::Line(_)) => true, (SelectionKey::Line(_), _) => false, } }) .unwrap_or(self.selection_key) } fn select_outer(&self, fold_section: bool) -> StateUpdate { match self.selection_key { SelectionKey::None => StateUpdate::None, selection_key @ SelectionKey::File(_) => { StateUpdate::SetExpandItem(selection_key, false) } selection_key @ SelectionKey::Section(SectionKey { commit_idx, file_idx, section_idx: _, }) => { // If folding is requested and the selection is expanded, // collapse it. Otherwise, move the selection to the file. if fold_section && self.expanded_items.contains(&selection_key) { StateUpdate::SetExpandItem(selection_key, false) } else { StateUpdate::SelectItem { selection_key: SelectionKey::File(FileKey { commit_idx, file_idx, }), ensure_in_viewport: true, } } } SelectionKey::Line(LineKey { commit_idx, file_idx, section_idx, line_idx: _, }) => StateUpdate::SelectItem { selection_key: SelectionKey::Section(SectionKey { commit_idx, file_idx, section_idx, }), ensure_in_viewport: true, }, } } fn advance_to_next_of_kind(&self) -> SelectionKey { let (keys, index) = self.find_selection(); let index = match index { Some(index) => index, None => return SelectionKey::None, }; keys.iter() .skip(index + 1) .copied() .find(|key| match (self.selection_key, key) { (SelectionKey::None, _) | (SelectionKey::File(_), SelectionKey::File(_)) | (SelectionKey::Section(_), SelectionKey::Section(_)) | (SelectionKey::Line(_), SelectionKey::Line(_)) => true, ( SelectionKey::File(_), SelectionKey::None | SelectionKey::Section(_) | SelectionKey::Line(_), ) | ( SelectionKey::Section(_), SelectionKey::None | SelectionKey::File(_) | SelectionKey::Line(_), ) | ( SelectionKey::Line(_), SelectionKey::None | SelectionKey::File(_) | SelectionKey::Section(_), ) => false, }) .unwrap_or(self.selection_key) } fn selection_key_y( &self, drawn_rects: &DrawnRects, selection_key: SelectionKey, ) -> Option { let rect = self.selection_rect(drawn_rects, selection_key)?; Some(rect.y) } fn selection_rect( &self, drawn_rects: &DrawnRects, selection_key: SelectionKey, ) -> Option { let id = match selection_key { SelectionKey::None => return None, SelectionKey::File(_) | SelectionKey::Section(_) | SelectionKey::Line(_) => { ComponentId::SelectableItem(selection_key) } }; match drawn_rects.get(&id) { Some(DrawnRect { rect, timestamp: _ }) => Some(*rect), None => { if cfg!(debug_assertions) { panic!( "could not look up drawn rect for component with ID {id:?}; was it drawn?" ) } else { warn!(component_id = ?id, "could not look up drawn rect for component; was it drawn?"); None } } } } fn ensure_in_viewport( &self, term_height: usize, drawn_rects: &DrawnRects, selection_key: SelectionKey, ) -> Option { let menu_bar_height = 1; let sticky_file_header_height = match selection_key { SelectionKey::None | SelectionKey::File(_) => 0, SelectionKey::Section(_) | SelectionKey::Line(_) => 1, }; let top_margin = sticky_file_header_height + menu_bar_height; let viewport_top_y = self.scroll_offset_y + top_margin; let viewport_height = term_height.unwrap_isize() - top_margin; let viewport_bottom_y = viewport_top_y + viewport_height; let selection_rect = self.selection_rect(drawn_rects, selection_key)?; let selection_top_y = selection_rect.y; let selection_height = selection_rect.height.unwrap_isize(); let selection_bottom_y = selection_top_y + selection_height; // Idea: scroll the entire component into the viewport, not just the // first line, if possible. If the entire component is smaller than // the viewport, then we scroll only enough so that the entire // component becomes visible, i.e. align the component's bottom edge // with the viewport's bottom edge. Otherwise, we scroll such that // the component's top edge is aligned with the viewport's top edge. // // FIXME: if we scroll up from below, we would want to align the top // edge of the component, not the bottom edge. Thus, we should also // accept the previous `SelectionKey` and use that when making the // decision of where to scroll. let result = if viewport_top_y <= selection_top_y && selection_bottom_y < viewport_bottom_y { // Component is completely within the viewport, no need to scroll. self.scroll_offset_y } else if ( // Component doesn't fit in the viewport; just render the top. selection_height >= viewport_height ) || ( // Component is at least partially above the viewport. selection_top_y < viewport_top_y ) { selection_top_y - top_margin } else { // Component is at least partially below the viewport. Want to satisfy: // scroll_offset_y + term_height == rect_bottom_y selection_bottom_y - top_margin - viewport_height }; Some(result) } fn find_component_at( &self, drawn_rects: &DrawnRects, row: usize, column: usize, ) -> ComponentId { let x = column.unwrap_isize(); let y = row.unwrap_isize() + self.scroll_offset_y; drawn_rects .iter() .filter(|(id, drawn_rect)| { let DrawnRect { rect, timestamp: _ } = drawn_rect; rect.contains_point(x, y) && match id { ComponentId::App | ComponentId::AppFiles | ComponentId::MenuHeader | ComponentId::CommitMessageView => false, ComponentId::MenuBar | ComponentId::MenuItem(_) | ComponentId::Menu(_) | ComponentId::CommitEditMessageButton(_) | ComponentId::FileViewHeader(_) | ComponentId::SelectableItem(_) | ComponentId::ToggleBox(_) | ComponentId::ExpandBox(_) | ComponentId::HelpDialog | ComponentId::HelpDialogQuitButton | ComponentId::QuitDialog | ComponentId::QuitDialogButton(_) => true, } }) .max_by_key(|(id, rect)| { let DrawnRect { rect: _, timestamp } = rect; (timestamp, *id) }) .map(|(id, _rect)| *id) .unwrap_or(ComponentId::App) } fn click_component(&self, menu_bar: &MenuBar, component_id: ComponentId) -> StateUpdate { match component_id { ComponentId::App | ComponentId::AppFiles | ComponentId::MenuHeader | ComponentId::CommitMessageView | ComponentId::QuitDialog => StateUpdate::None, ComponentId::MenuBar => StateUpdate::UnfocusMenuBar, ComponentId::Menu(section_idx) => StateUpdate::ClickMenu { menu_idx: section_idx, }, ComponentId::MenuItem(item_idx) => { StateUpdate::ClickMenuItem(self.get_menu_item_event(menu_bar, item_idx)) } ComponentId::CommitEditMessageButton(commit_idx) => { StateUpdate::EditCommitMessage { commit_idx } } ComponentId::FileViewHeader(file_key) => StateUpdate::SelectItem { selection_key: SelectionKey::File(file_key), ensure_in_viewport: false, }, ComponentId::SelectableItem(selection_key) => StateUpdate::SelectItem { selection_key, ensure_in_viewport: false, }, ComponentId::ToggleBox(selection_key) => { if self.selection_key == selection_key { StateUpdate::ToggleItem(selection_key) } else { StateUpdate::SelectItem { selection_key, ensure_in_viewport: false, } } } ComponentId::ExpandBox(selection_key) => { if self.selection_key == selection_key { StateUpdate::ToggleExpandItem(selection_key) } else { StateUpdate::SelectItem { selection_key, ensure_in_viewport: false, } } } ComponentId::QuitDialogButton(QuitDialogButtonId::GoBack) => { StateUpdate::SetQuitDialog(None) } ComponentId::QuitDialogButton(QuitDialogButtonId::Quit) => StateUpdate::QuitCancel, ComponentId::HelpDialog => StateUpdate::None, ComponentId::HelpDialogQuitButton => StateUpdate::SetHelpDialog(None), } } fn get_menu_item_event(&self, menu_bar: &MenuBar, item_idx: usize) -> Event { let MenuBar { menus, expanded_menu_idx, } = menu_bar; let menu_idx = match expanded_menu_idx { Some(section_idx) => section_idx, None => { warn!(?item_idx, "Clicking menu item when no menu is expanded"); return Event::None; } }; let menu = match menus.get(*menu_idx) { Some(menu) => menu, None => { warn!(?menu_idx, "Clicking out-of-bounds menu"); return Event::None; } }; let item = match menu.items.get(item_idx) { Some(item) => item, None => { warn!( ?menu_idx, ?item_idx, "Clicking menu bar section item that is out of bounds" ); return Event::None; } }; item.event.clone() } fn toggle_item(&mut self, selection: SelectionKey) -> Result<(), RecordError> { if self.state.is_read_only { return Ok(()); } match selection { SelectionKey::None => {} SelectionKey::File(file_key) => { let tristate = self.file_tristate(file_key)?; let is_checked_new = match tristate { Tristate::False => true, Tristate::Partial | Tristate::True => false, }; self.visit_file(file_key, |file| { file.set_checked(is_checked_new); })?; } SelectionKey::Section(section_key) => { let tristate = self.section_tristate(section_key)?; let is_checked_new = match tristate { Tristate::False => true, Tristate::Partial | Tristate::True => false, }; self.visit_section(section_key, |section| { section.set_checked(is_checked_new); })?; } SelectionKey::Line(line_key) => { self.visit_line(line_key, |line| { line.is_checked = !line.is_checked; })?; } } Ok(()) } fn toggle_all(&mut self) { if self.state.is_read_only { return; } for file in &mut self.state.files { file.toggle_all(); } } fn toggle_all_uniform(&mut self) { if self.state.is_read_only { return; } let checked = { let tristate = self .state .files .iter() .map(|file| file.tristate()) .fold(None, |acc, elem| match (acc, elem) { (None, tristate) => Some(tristate), (Some(acc_tristate), tristate) if acc_tristate == tristate => Some(tristate), _ => Some(Tristate::Partial), }) .unwrap_or(Tristate::False); match tristate { Tristate::False | Tristate::Partial => true, Tristate::True => false, } }; for file in &mut self.state.files { file.set_checked(checked); } } fn expand_item_ancestors(&mut self, selection: SelectionKey) { match selection { SelectionKey::None | SelectionKey::File(_) => {} SelectionKey::Section(SectionKey { commit_idx, file_idx, section_idx: _, }) => { self.expanded_items.insert(SelectionKey::File(FileKey { commit_idx, file_idx, })); } SelectionKey::Line(LineKey { commit_idx, file_idx, section_idx, line_idx: _, }) => { self.expanded_items.insert(SelectionKey::File(FileKey { commit_idx, file_idx, })); self.expanded_items .insert(SelectionKey::Section(SectionKey { commit_idx, file_idx, section_idx, })); } } } fn set_expand_item(&mut self, selection: SelectionKey, is_expanded: bool) { if is_expanded { self.expanded_items.insert(selection); } else { self.expanded_items.remove(&selection); } } fn toggle_expand_item(&mut self, selection: SelectionKey) -> Result<(), RecordError> { match selection { SelectionKey::None => {} SelectionKey::File(file_key) => { if !self.expanded_items.insert(SelectionKey::File(file_key)) { self.expanded_items.remove(&SelectionKey::File(file_key)); } } SelectionKey::Section(section_key) => { if !self .expanded_items .insert(SelectionKey::Section(section_key)) { self.expanded_items .remove(&SelectionKey::Section(section_key)); } } SelectionKey::Line(_) => { // Do nothing. } } Ok(()) } fn expand_initial_items(&mut self) { self.expanded_items = self .all_selection_keys() .into_iter() .filter(|selection_key| match selection_key { SelectionKey::None | SelectionKey::File(_) | SelectionKey::Line(_) => false, SelectionKey::Section(_) => true, }) .collect(); } fn toggle_expand_all(&mut self) -> Result<(), RecordError> { let all_selection_keys: HashSet<_> = self.all_selection_keys().into_iter().collect(); self.expanded_items = if self.expanded_items == all_selection_keys { // Select an ancestor file key that will still be visible. self.selection_key = match self.selection_key { selection_key @ (SelectionKey::None | SelectionKey::File(_)) => selection_key, SelectionKey::Section(SectionKey { commit_idx, file_idx, section_idx: _, }) | SelectionKey::Line(LineKey { commit_idx, file_idx, section_idx: _, line_idx: _, }) => SelectionKey::File(FileKey { commit_idx, file_idx, }), }; Default::default() } else { all_selection_keys }; Ok(()) } fn unfocus_menu_bar(&mut self) { self.expanded_menu_idx = None; } fn click_menu_header(&mut self, menu_idx: usize) { let menu_idx = Some(menu_idx); self.expanded_menu_idx = if self.expanded_menu_idx == menu_idx { None } else { menu_idx }; } fn click_menu_item(&mut self, event: Event) { self.expanded_menu_idx = None; self.pending_events.push(event); } fn edit_commit_message(&mut self, commit_idx: usize) -> Result<(), RecordError> { let message = &mut self.state.commits[commit_idx].message; let message_str = match message.as_ref() { Some(message) => message, None => return Ok(()), }; let new_message = { match self.input.terminal_kind() { TerminalKind::Testing { .. } => {} TerminalKind::Crossterm => { Self::clean_up_crossterm()?; } } let result = self.input.edit_commit_message(message_str); match self.input.terminal_kind() { TerminalKind::Testing { .. } => {} TerminalKind::Crossterm => { Self::set_up_crossterm()?; } } result? }; *message = Some(new_message); Ok(()) } fn file(&self, file_key: FileKey) -> Result<&File, RecordError> { let FileKey { commit_idx: _, file_idx, } = file_key; match self.state.files.get(file_idx) { Some(file) => Ok(file), None => Err(RecordError::Bug(format!( "Out-of-bounds file key: {file_key:?}" ))), } } fn section(&self, section_key: SectionKey) -> Result<&Section, RecordError> { let SectionKey { commit_idx, file_idx, section_idx, } = section_key; let file = self.file(FileKey { commit_idx, file_idx, })?; match file.sections.get(section_idx) { Some(section) => Ok(section), None => Err(RecordError::Bug(format!( "Out-of-bounds section key: {section_key:?}" ))), } } fn visit_file( &mut self, file_key: FileKey, f: impl Fn(&mut File) -> T, ) -> Result { let FileKey { commit_idx: _, file_idx, } = file_key; match self.state.files.get_mut(file_idx) { Some(file) => Ok(f(file)), None => Err(RecordError::Bug(format!( "Out-of-bounds file key: {file_key:?}" ))), } } fn file_tristate(&self, file_key: FileKey) -> Result { let file = self.file(file_key)?; Ok(file.tristate()) } fn file_expanded(&self, file_key: FileKey) -> Tristate { let is_expanded = self.expanded_items.contains(&SelectionKey::File(file_key)); if !is_expanded { Tristate::False } else { let any_section_unexpanded = self .file(file_key) .unwrap() .sections .iter() .enumerate() .any(|(section_idx, section)| { match section { Section::Unchanged { .. } | Section::FileMode { .. } | Section::Binary { .. } => { // Not collapsible/expandable. false } Section::Changed { .. } => { let section_key = SectionKey { commit_idx: file_key.commit_idx, file_idx: file_key.file_idx, section_idx, }; !self .expanded_items .contains(&SelectionKey::Section(section_key)) } } }); if any_section_unexpanded { Tristate::Partial } else { Tristate::True } } } fn visit_section( &mut self, section_key: SectionKey, f: impl Fn(&mut Section) -> T, ) -> Result { let SectionKey { commit_idx: _, file_idx, section_idx, } = section_key; let file = match self.state.files.get_mut(file_idx) { Some(file) => file, None => { return Err(RecordError::Bug(format!( "Out-of-bounds file for section key: {section_key:?}" ))); } }; match file.sections.get_mut(section_idx) { Some(section) => Ok(f(section)), None => Err(RecordError::Bug(format!( "Out-of-bounds section key: {section_key:?}" ))), } } fn section_tristate(&self, section_key: SectionKey) -> Result { let section = self.section(section_key)?; Ok(section.tristate()) } fn visit_line( &mut self, line_key: LineKey, f: impl FnOnce(&mut SectionChangedLine), ) -> Result<(), RecordError> { let LineKey { commit_idx: _, file_idx, section_idx, line_idx, } = line_key; let section = &mut self.state.files[file_idx].sections[section_idx]; match section { Section::Changed { lines } => { let line = &mut lines[line_idx]; f(line); Ok(()) } Section::Unchanged { .. } | Section::FileMode { .. } | Section::Binary { .. } => { // Do nothing. Ok(()) } } } } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] enum ComponentId { App, AppFiles, MenuBar, MenuHeader, Menu(usize), MenuItem(usize), CommitMessageView, CommitEditMessageButton(usize), FileViewHeader(FileKey), SelectableItem(SelectionKey), ToggleBox(SelectionKey), ExpandBox(SelectionKey), QuitDialog, QuitDialogButton(QuitDialogButtonId), HelpDialog, HelpDialogQuitButton, } #[derive(Clone, Debug)] enum TristateIconStyle { Check, Expand, } #[derive(Clone, Debug)] struct TristateBox { use_unicode: bool, id: Id, tristate: Tristate, icon_style: TristateIconStyle, is_focused: bool, is_read_only: bool, } impl TristateBox { fn text(&self) -> String { let Self { use_unicode, id: _, tristate, icon_style, is_focused, is_read_only, } = self; let (l, r) = match (is_read_only, is_focused) { (true, _) => ("<", ">"), (false, false) => ("[", "]"), (false, true) => ("(", ")"), }; let inner = match (icon_style, tristate, use_unicode) { (TristateIconStyle::Expand, Tristate::False, _) => "+", (TristateIconStyle::Expand, Tristate::True, _) => "-", (TristateIconStyle::Expand, Tristate::Partial, false) => "~", (TristateIconStyle::Expand, Tristate::Partial, true) => "±", (TristateIconStyle::Check, Tristate::False, false) => " ", (TristateIconStyle::Check, Tristate::True, false) => "*", (TristateIconStyle::Check, Tristate::Partial, false) => "~", (TristateIconStyle::Check, Tristate::False, true) => " ", (TristateIconStyle::Check, Tristate::True, true) => "●", (TristateIconStyle::Check, Tristate::Partial, true) => "◐", }; format!("{l}{inner}{r}") } } impl Component for TristateBox { type Id = Id; fn id(&self) -> Self::Id { self.id.clone() } fn draw(&self, viewport: &mut Viewport, x: isize, y: isize) { let style = if self.is_read_only { Style::default().fg(Color::Gray).add_modifier(Modifier::DIM) } else { Style::default().add_modifier(Modifier::BOLD) }; let span = Span::styled(self.text(), style); viewport.draw_span(x, y, &span); } } #[allow(dead_code)] #[derive(Clone, Debug)] struct AppDebugInfo { term_height: usize, scroll_offset_y: isize, selection_key: SelectionKey, selection_key_y: Option, drawn_rects: BTreeMap, // sorted for determinism } #[derive(Clone, Debug)] struct AppView<'a> { debug_info: Option, menu_bar: MenuBar<'a>, commit_view_mode: CommitViewMode, commit_views: Vec>, quit_dialog: Option, help_dialog: Option, } impl Component for AppView<'_> { type Id = ComponentId; fn id(&self) -> Self::Id { ComponentId::App } fn draw(&self, viewport: &mut Viewport, x: isize, _y: isize) { let Self { debug_info, menu_bar, commit_view_mode, commit_views, quit_dialog, help_dialog, } = self; if let Some(debug_info) = debug_info { viewport.debug(format!("app debug info: {debug_info:#?}")); } let viewport_rect = viewport.mask_rect(); let menu_bar_height = 1usize; let commit_view_width = match commit_view_mode { CommitViewMode::Inline => viewport.rect().width, CommitViewMode::Adjacent => { const MAX_COMMIT_VIEW_WIDTH: usize = 120; MAX_COMMIT_VIEW_WIDTH .min(viewport.rect().width.saturating_sub(CommitView::MARGIN) / 2) } }; let commit_views_mask = Mask { x: viewport_rect.x, y: viewport_rect.y + menu_bar_height.unwrap_isize(), width: Some(viewport_rect.width), height: None, }; viewport.with_mask(commit_views_mask, |viewport| { let mut commit_view_x = 0; for commit_view in commit_views { let commit_view_mask = Mask { x: commit_views_mask.x + commit_view_x, y: commit_views_mask.y, width: Some(commit_view_width), height: None, }; let commit_view_rect = viewport.with_mask(commit_view_mask, |viewport| { viewport.draw_component( commit_view_x, menu_bar_height.unwrap_isize(), commit_view, ) }); commit_view_x += (CommitView::MARGIN + commit_view_mask.apply(commit_view_rect).width) .unwrap_isize(); } }); viewport.draw_component(x, viewport_rect.y, menu_bar); if let Some(quit_dialog) = quit_dialog { viewport.draw_component(0, 0, quit_dialog); } if let Some(help_dialog) = help_dialog { viewport.draw_component(0, 0, help_dialog); } } } #[derive(Clone, Debug)] struct CommitMessageView<'a> { commit_idx: usize, commit: &'a Commit, } impl Component for CommitMessageView<'_> { type Id = ComponentId; fn id(&self) -> Self::Id { ComponentId::CommitMessageView } fn draw(&self, viewport: &mut Viewport, x: isize, y: isize) { let Self { commit_idx, commit } = self; match commit { Commit { message: None } => {} Commit { message: Some(message), } => { viewport.draw_blank(Rect { x, y, width: viewport.mask_rect().width, height: 1, }); let y = y + 1; let style = Style::default(); let button_rect = viewport.draw_component( x, y, &Button { id: ComponentId::CommitEditMessageButton(*commit_idx), label: Cow::Borrowed("Edit message"), style, is_focused: false, }, ); let divider_rect = viewport.draw_span(button_rect.end_x() + 1, y, &Span::raw(" • ")); viewport.draw_text( divider_rect.end_x() + 1, y, Span::styled( Cow::Borrowed({ let first_line = match message.split_once('\n') { Some((before, _after)) => before, None => message, }; let first_line = first_line.trim(); if first_line.is_empty() { "(no message)" } else { first_line } }), style.add_modifier(Modifier::UNDERLINED), ), ); let y = y + 1; viewport.draw_blank(Rect { x, y, width: viewport.mask_rect().width, height: 1, }); } } } } #[derive(Clone, Debug)] struct CommitView<'a> { debug_info: Option<&'a AppDebugInfo>, commit_message_view: CommitMessageView<'a>, file_views: Vec>, } impl CommitView<'_> { const MARGIN: usize = 1; } impl Component for CommitView<'_> { type Id = ComponentId; fn id(&self) -> Self::Id { ComponentId::AppFiles } fn draw(&self, viewport: &mut Viewport, x: isize, y: isize) { let Self { debug_info, commit_message_view, file_views, } = self; let commit_message_view_rect = viewport.draw_component(x, y, commit_message_view); if file_views.is_empty() { let message = "There are no changes to view."; let message_rect = centered_rect( Rect { x, y, width: viewport.mask_rect().width, height: viewport.mask_rect().height, }, RectSize { width: message.len(), height: 1, }, 50, 50, ); viewport.draw_text(message_rect.x, message_rect.y, Span::raw(message)); return; } let mut y = y; y += commit_message_view_rect.height.unwrap_isize(); for file_view in file_views { let file_view_rect = { let file_view_mask = Mask { x, y, width: viewport.mask().width, height: None, }; viewport.with_mask(file_view_mask, |viewport| { viewport.draw_component(x, y, file_view) }) }; // Render a sticky header if necessary. let mask = viewport.mask(); if file_view_rect.y < mask.y && mask.y < file_view_rect.y + file_view_rect.height.unwrap_isize() { viewport.with_mask( Mask { x, y: mask.y, width: Some(viewport.mask_rect().width), height: Some(1), }, |viewport| { viewport.draw_component( x, mask.y, &FileViewHeader { file_key: file_view.file_key, path: file_view.path, old_path: file_view.old_path, is_selected: file_view.is_header_selected, toggle_box: file_view.toggle_box.clone(), expand_box: file_view.expand_box.clone(), }, ); }, ); } y += file_view_rect.height.unwrap_isize(); if debug_info.is_some() { viewport.debug(format!( "file {} dims: {file_view_rect:?}", file_view.path.to_string_lossy() )); } } } } #[derive(Clone, Debug)] struct MenuItem<'a> { label: Cow<'a, str>, event: Event, } #[derive(Clone, Debug)] struct Menu<'a> { label: Cow<'a, str>, items: Vec>, } impl Component for Menu<'_> { type Id = ComponentId; fn id(&self) -> Self::Id { ComponentId::MenuHeader } fn draw(&self, viewport: &mut Viewport, x: isize, y: isize) { let Self { label: _, items } = self; let buttons = items .iter() .enumerate() .map(|(i, item)| Button { id: ComponentId::MenuItem(i), label: Cow::Borrowed(&item.label), style: Style::default(), is_focused: false, }) .collect::>(); let max_width = buttons .iter() .map(|button| button.width()) .max() .unwrap_or_default(); let mut y = y; for button in buttons { viewport.draw_span( x, y, &Span::styled( " ".repeat(max_width), Style::reset().add_modifier(Modifier::REVERSED), ), ); viewport.draw_component(x, y, &button); y += 1; } } } #[derive(Clone, Debug)] struct MenuBar<'a> { menus: Vec>, expanded_menu_idx: Option, } impl Component for MenuBar<'_> { type Id = ComponentId; fn id(&self) -> Self::Id { ComponentId::MenuBar } fn draw(&self, viewport: &mut Viewport, x: isize, y: isize) { let Self { menus, expanded_menu_idx, } = self; viewport.draw_blank(viewport.rect().top_row()); highlight_rect(viewport, viewport.rect().top_row()); let mut x = x; for (i, menu) in menus.iter().enumerate() { let menu_header = Button { id: ComponentId::Menu(i), label: Cow::Borrowed(&menu.label), style: Style::default(), is_focused: false, }; let rect = viewport.draw_component(x, y, &menu_header); if expanded_menu_idx == &Some(i) { viewport.draw_component(x, y + 1, menu); } x += rect.width.unwrap_isize() + 1; } } } #[derive(Clone, Debug)] struct FileView<'a> { debug: bool, file_key: FileKey, toggle_box: TristateBox, expand_box: TristateBox, is_header_selected: bool, old_path: Option<&'a Path>, path: &'a Path, section_views: Vec>, } impl FileView<'_> { fn is_expanded(&self) -> bool { match self.expand_box.tristate { Tristate::False => false, Tristate::Partial | Tristate::True => true, } } } impl Component for FileView<'_> { type Id = ComponentId; fn id(&self) -> Self::Id { ComponentId::SelectableItem(SelectionKey::File(self.file_key)) } fn draw(&self, viewport: &mut Viewport, x: isize, y: isize) { let Self { debug, file_key, toggle_box, expand_box, old_path, path, section_views, is_header_selected, } = self; let file_view_header_rect = viewport.draw_component( x, y, &FileViewHeader { file_key: *file_key, path, old_path: *old_path, is_selected: *is_header_selected, toggle_box: toggle_box.clone(), expand_box: expand_box.clone(), }, ); if self.is_expanded() { let x = x + 2; let mut section_y = y + file_view_header_rect.height.unwrap_isize(); let expanded_sections: HashSet = section_views .iter() .enumerate() .filter_map(|(i, view)| { if view.is_expanded() && view.section.is_editable() { return Some(i); } None }) .collect(); for (i, section_view) in section_views.iter().enumerate() { // Skip this section if it is an un-editable context section and // none of the editable sections surrounding it are expanded. let context_section = !section_view.section.is_editable(); let prev_is_collapsed = i == 0 || !expanded_sections.contains(&(i - 1)); let next_is_collapsed = !expanded_sections.contains(&(i + 1)); if context_section && prev_is_collapsed && next_is_collapsed { continue; } let section_rect = viewport.draw_component(x, section_y, section_view); section_y += section_rect.height.unwrap_isize(); if *debug { viewport.debug(format!("section dims: {section_rect:?}",)); } } } } } struct FileViewHeader<'a> { file_key: FileKey, path: &'a Path, old_path: Option<&'a Path>, is_selected: bool, toggle_box: TristateBox, expand_box: TristateBox, } impl Component for FileViewHeader<'_> { type Id = ComponentId; fn id(&self) -> Self::Id { let Self { file_key, path: _, old_path: _, is_selected: _, toggle_box: _, expand_box: _, } = self; ComponentId::FileViewHeader(*file_key) } fn draw(&self, viewport: &mut Viewport, x: isize, y: isize) { let Self { file_key: _, path, old_path, is_selected, toggle_box, expand_box, } = self; // Draw expand box at end of line. let expand_box_width = expand_box.text().width().unwrap_isize(); let expand_box_rect = viewport.draw_component( viewport.mask_rect().end_x() - expand_box_width, y, expand_box, ); viewport.with_mask( Mask { x, y, width: Some((expand_box_rect.x - x).clamp_into_usize()), height: Some(1), }, |viewport| { viewport.draw_blank(Rect { x, y, width: viewport.mask_rect().width, height: 1, }); let toggle_box_rect = viewport.draw_component(x, y, toggle_box); viewport.draw_text( x + toggle_box_rect.width.unwrap_isize() + 1, y, Span::styled( format!( "{}{}", match old_path { Some(old_path) => format!("{} => ", old_path.to_string_lossy()), None => String::new(), }, path.to_string_lossy(), ), if *is_selected { Style::default().fg(Color::Blue) } else { Style::default() }, ), ); }, ); if *is_selected { highlight_rect( viewport, Rect { x: viewport.mask_rect().x, y, width: viewport.mask_rect().width, height: 1, }, ); } } } #[derive(Clone, Debug)] enum SectionSelection { SectionHeader, ChangedLine(usize), } #[derive(Clone, Debug)] struct SectionView<'a> { use_unicode: bool, is_read_only: bool, section_key: SectionKey, toggle_box: TristateBox, expand_box: TristateBox, selection: Option, total_num_sections: usize, editable_section_num: usize, total_num_editable_sections: usize, section: &'a Section<'a>, line_start_num: usize, } impl SectionView<'_> { fn is_expanded(&self) -> bool { match self.expand_box.tristate { Tristate::False => false, Tristate::Partial => { // Shouldn't happen. true } Tristate::True => true, } } } impl Component for SectionView<'_> { type Id = ComponentId; fn id(&self) -> Self::Id { ComponentId::SelectableItem(SelectionKey::Section(self.section_key)) } fn draw(&self, viewport: &mut Viewport, x: isize, y: isize) { let Self { use_unicode, is_read_only, section_key, toggle_box, expand_box, selection, total_num_sections, editable_section_num, total_num_editable_sections, section, line_start_num, } = self; viewport.draw_blank(Rect { x, y, width: viewport.mask_rect().width, height: 1, }); let SectionKey { commit_idx, file_idx, section_idx, } = *section_key; match section { Section::Unchanged { lines } => { if lines.is_empty() { return; } let lines: Vec<_> = lines.iter().enumerate().collect(); let is_first_section = section_idx == 0; let is_last_section = section_idx + 1 == *total_num_sections; let before_ellipsis_lines = &lines[..min(NUM_CONTEXT_LINES, lines.len())]; let after_ellipsis_lines = &lines[lines.len().saturating_sub(NUM_CONTEXT_LINES)..]; match (before_ellipsis_lines, after_ellipsis_lines) { ([.., (last_before_idx, _)], [(first_after_idx, _), ..]) if *last_before_idx + 1 >= *first_after_idx && !is_first_section && !is_last_section => { let first_before_idx = before_ellipsis_lines.first().unwrap().0; let last_after_idx = after_ellipsis_lines.last().unwrap().0; let overlapped_lines = &lines[first_before_idx..=last_after_idx]; let overlapped_lines = if is_first_section { &overlapped_lines [overlapped_lines.len().saturating_sub(NUM_CONTEXT_LINES)..] } else if is_last_section { &overlapped_lines[..lines.len().min(NUM_CONTEXT_LINES)] } else { overlapped_lines }; for (dy, (line_idx, line)) in overlapped_lines.iter().enumerate() { let line_view = SectionLineView { line_key: LineKey { commit_idx, file_idx, section_idx, line_idx: *line_idx, }, inner: SectionLineViewInner::Unchanged { line: line.as_ref(), line_num: line_start_num + line_idx, }, }; viewport.draw_component(x + 2, y + dy.unwrap_isize(), &line_view); } return; } _ => {} }; let mut dy = 0; if !is_first_section { for (line_idx, line) in before_ellipsis_lines { let line_view = SectionLineView { line_key: LineKey { commit_idx, file_idx, section_idx, line_idx: *line_idx, }, inner: SectionLineViewInner::Unchanged { line: line.as_ref(), line_num: line_start_num + line_idx, }, }; viewport.draw_component(x + 2, y + dy, &line_view); dy += 1; } } let should_render_ellipsis = lines.len() > NUM_CONTEXT_LINES; if should_render_ellipsis { let ellipsis = if *use_unicode { "\u{22EE}" // Vertical Ellipsis } else { ":" }; viewport.draw_span( x + 6, // align with line numbering y + dy, &Span::styled(ellipsis, Style::default().add_modifier(Modifier::DIM)), ); dy += 1; } if !is_last_section { for (line_idx, line) in after_ellipsis_lines { let line_view = SectionLineView { line_key: LineKey { commit_idx, file_idx, section_idx, line_idx: *line_idx, }, inner: SectionLineViewInner::Unchanged { line: line.as_ref(), line_num: line_start_num + line_idx, }, }; viewport.draw_component(x + 2, y + dy, &line_view); dy += 1; } } } Section::Changed { lines } => { // Draw expand box at end of line. let expand_box_width = expand_box.text().width().unwrap_isize(); let expand_box_rect = viewport.draw_component( viewport.mask_rect().width.unwrap_isize() - expand_box_width, y, expand_box, ); // Draw section header. viewport.with_mask( Mask { x, y, width: Some((expand_box_rect.x - x).clamp_into_usize()), height: Some(1), }, |viewport| { let toggle_box_rect = viewport.draw_component(x, y, toggle_box); viewport.draw_text( x + toggle_box_rect.width.unwrap_isize() + 1, y, Span::styled( format!( "Section {editable_section_num}/{total_num_editable_sections}" ), Style::default(), ), ) }, ); match selection { Some(SectionSelection::SectionHeader) => { highlight_rect( viewport, Rect { x: viewport.mask_rect().x, y, width: viewport.mask_rect().width, height: 1, }, ); } Some(SectionSelection::ChangedLine(_)) | None => {} } if self.is_expanded() { // Draw changed lines. let y = y + 1; for (line_idx, line) in lines.iter().enumerate() { let SectionChangedLine { is_checked, change_type, line, } = line; let is_focused = match selection { Some(SectionSelection::ChangedLine(selected_line_idx)) => { line_idx == *selected_line_idx } Some(SectionSelection::SectionHeader) | None => false, }; let line_key = LineKey { commit_idx, file_idx, section_idx, line_idx, }; let toggle_box = TristateBox { use_unicode: *use_unicode, id: ComponentId::ToggleBox(SelectionKey::Line(line_key)), icon_style: TristateIconStyle::Check, tristate: Tristate::from(*is_checked), is_focused, is_read_only: *is_read_only, }; let line_view = SectionLineView { line_key, inner: SectionLineViewInner::Changed { toggle_box, change_type: *change_type, line: line.as_ref(), }, }; let y = y + line_idx.unwrap_isize(); viewport.draw_component(x + 2, y, &line_view); if is_focused { highlight_rect( viewport, Rect { x: viewport.mask_rect().x, y, width: viewport.mask_rect().width, height: 1, }, ); } } } } Section::FileMode { is_checked, before, after, } => { let is_focused = match selection { Some(SectionSelection::SectionHeader) => true, Some(SectionSelection::ChangedLine(_)) | None => false, }; let section_key = SectionKey { commit_idx, file_idx, section_idx, }; let selection_key = SelectionKey::Section(section_key); let toggle_box = TristateBox { use_unicode: *use_unicode, id: ComponentId::ToggleBox(selection_key), icon_style: TristateIconStyle::Check, tristate: Tristate::from(*is_checked), is_focused, is_read_only: *is_read_only, }; let toggle_box_rect = viewport.draw_component(x, y, &toggle_box); let x = x + toggle_box_rect.width.unwrap_isize() + 1; let text = format!("File mode changed from {before} to {after}"); viewport.draw_text(x, y, Span::styled(text, Style::default().fg(Color::Blue))); if is_focused { highlight_rect( viewport, Rect { x: viewport.mask_rect().x, y, width: viewport.mask_rect().width, height: 1, }, ); } } Section::Binary { is_checked, old_description, new_description, } => { let is_focused = match selection { Some(SectionSelection::SectionHeader) => true, Some(SectionSelection::ChangedLine(_)) | None => false, }; let section_key = SectionKey { commit_idx, file_idx, section_idx, }; let toggle_box = TristateBox { use_unicode: *use_unicode, id: ComponentId::ToggleBox(SelectionKey::Section(section_key)), icon_style: TristateIconStyle::Check, tristate: Tristate::from(*is_checked), is_focused, is_read_only: *is_read_only, }; let toggle_box_rect = viewport.draw_component(x, y, &toggle_box); let x = x + toggle_box_rect.width.unwrap_isize() + 1; let text = { let mut result = vec![if old_description.is_some() || new_description.is_some() { "binary contents:" } else { "binary contents" } .to_string()]; let description: Vec<_> = [old_description, new_description] .iter() .copied() .flatten() .map(|s| s.as_ref()) .collect(); result.push(description.join(" -> ")); format!("({})", result.join(" ")) }; viewport.draw_text(x, y, Span::styled(text, Style::default().fg(Color::Blue))); if is_focused { highlight_rect( viewport, Rect { x: viewport.mask_rect().x, y, width: viewport.mask_rect().width, height: 1, }, ); } } } } } #[derive(Clone, Debug)] enum SectionLineViewInner<'a> { Unchanged { line: &'a str, line_num: usize, }, Changed { toggle_box: TristateBox, change_type: ChangeType, line: &'a str, }, } fn replace_control_character(character: char) -> Option<&'static str> { match character { // Characters end up writing over each-other and end up // displaying incorrectly if ignored. Replacing tabs // with a known length string fixes the issue for now. '\t' => Some("→ "), '\n' => Some("⏎"), '\r' => Some("␍"), '\x00' => Some("␀"), '\x01' => Some("␁"), '\x02' => Some("␂"), '\x03' => Some("␃"), '\x04' => Some("␄"), '\x05' => Some("␅"), '\x06' => Some("␆"), '\x07' => Some("␇"), '\x08' => Some("␈"), // '\x09' ('\t') handled above // '\x0A' ('\n') handled above '\x0B' => Some("␋"), '\x0C' => Some("␌"), // '\x0D' ('\r') handled above '\x0E' => Some("␎"), '\x0F' => Some("␏"), '\x10' => Some("␐"), '\x11' => Some("␑"), '\x12' => Some("␒"), '\x13' => Some("␓"), '\x14' => Some("␔"), '\x15' => Some("␕"), '\x16' => Some("␖"), '\x17' => Some("␗"), '\x18' => Some("␘"), '\x19' => Some("␙"), '\x1A' => Some("␚"), '\x1B' => Some("␛"), '\x1C' => Some("␜"), '\x1D' => Some("␝"), '\x1E' => Some("␞"), '\x1F' => Some("␟"), '\x7F' => Some("␡"), c if c.width().unwrap_or_default() == 0 => Some("�"), _ => None, } } /// Split the line into a sequence of [`Span`]s where control characters are /// replaced with styled [`Span`]'s and push them to the [`spans`] argument. fn push_spans_from_line<'line>(line: &'line str, spans: &mut Vec>) { const CONTROL_CHARACTER_STYLE: Style = Style::new().fg(Color::DarkGray); let mut last_index = 0; // Find index of the start of each character to replace for (idx, char) in line.match_indices(|char| replace_control_character(char).is_some()) { // Push the string leading up to the character and the styled replacement string if let Some(replacement_string) = char.chars().next().and_then(replace_control_character) { spans.push(Span::raw(&line[last_index..idx])); spans.push(Span::styled(replacement_string, CONTROL_CHARACTER_STYLE)); // Move the "cursor" to just after the character we're replacing last_index = idx + char.len(); } } // Append anything remaining after the last replacement let remaining_line = &line[last_index..]; if !remaining_line.is_empty() { spans.push(Span::raw(remaining_line)); } } #[derive(Clone, Debug)] struct SectionLineView<'a> { line_key: LineKey, inner: SectionLineViewInner<'a>, } impl Component for SectionLineView<'_> { type Id = ComponentId; fn id(&self) -> Self::Id { ComponentId::SelectableItem(SelectionKey::Line(self.line_key)) } fn draw(&self, viewport: &mut Viewport, x: isize, y: isize) { viewport.draw_blank(Rect { x: viewport.mask_rect().x, y, width: viewport.mask_rect().width, height: 1, }); match &self.inner { SectionLineViewInner::Unchanged { line, line_num } => { // Pad the number in 5 columns because that will align the // beginning of the actual text with the `+`/`-` of the changed // lines. let line_number = Span::raw(format!("{line_num:5} ")); let mut spans = vec![line_number]; push_spans_from_line(line, &mut spans); const UI_UNCHANGED_STYLE: Style = Style::new().add_modifier(Modifier::DIM); viewport.draw_text(x, y, Line::from(spans).style(UI_UNCHANGED_STYLE)); } SectionLineViewInner::Changed { toggle_box, change_type, line, } => { let toggle_box_rect = viewport.draw_component(x, y, toggle_box); let x = toggle_box_rect.end_x() + 1; let (change_type_text, changed_line_style) = match change_type { ChangeType::Added => ("+ ", Style::default().fg(Color::Green)), ChangeType::Removed => ("- ", Style::default().fg(Color::Red)), }; let mut spans = vec![Span::raw(change_type_text)]; push_spans_from_line(line, &mut spans); viewport.draw_text(x, y, Line::from(spans).style(changed_line_style)); } } } } #[derive(Clone, Debug, PartialEq, Eq)] struct QuitDialog { num_commit_messages: usize, num_changed_files: usize, focused_button: QuitDialogButtonId, } impl Component for QuitDialog { type Id = ComponentId; fn id(&self) -> Self::Id { ComponentId::QuitDialog } fn draw(&self, viewport: &mut Viewport, _x: isize, _y: isize) { let Self { num_commit_messages, num_changed_files, focused_button, } = self; let title = "Quit"; let alert_items = { let mut result = Vec::new(); if *num_commit_messages > 0 { result.push(format!( "{num_commit_messages} {}", if *num_commit_messages == 1 { "message" } else { "messages" } )); } if *num_changed_files > 0 { result.push(format!( "{num_changed_files} {}", if *num_changed_files == 1 { "file" } else { "files" } )); } result }; let alert = if alert_items.is_empty() { // Shouldn't happen. "".to_string() } else { format!("You have changes to {}. ", alert_items.join(" and ")) }; let body = format!("{alert}Are you sure you want to quit?",); let quit_button = Button { id: ComponentId::QuitDialogButton(QuitDialogButtonId::Quit), label: Cow::Borrowed("Quit"), style: Style::default(), is_focused: match focused_button { QuitDialogButtonId::Quit => true, QuitDialogButtonId::GoBack => false, }, }; let go_back_button = Button { id: ComponentId::QuitDialogButton(QuitDialogButtonId::GoBack), label: Cow::Borrowed("Go Back"), style: Style::default(), is_focused: match focused_button { QuitDialogButtonId::GoBack => true, QuitDialogButtonId::Quit => false, }, }; let buttons = [quit_button, go_back_button]; let dialog = Dialog { id: ComponentId::QuitDialog, title: Cow::Borrowed(title), body: Cow::Owned(body), buttons: &buttons, }; viewport.draw_component(0, 0, &dialog); } } #[derive(Debug, Clone, Eq, PartialEq)] struct HelpDialog(); impl Component for HelpDialog { type Id = ComponentId; fn id(&self) -> Self::Id { ComponentId::HelpDialog } fn draw(&self, viewport: &mut Viewport, _: isize, _: isize) { let title = "Help"; let body = "You can click the menus with the mouse to view keyboard shortcuts."; let quit_button = Button { id: ComponentId::HelpDialogQuitButton, label: Cow::Borrowed("Close"), style: Style::default(), is_focused: true, }; let buttons = [quit_button]; let dialog = Dialog { id: self.id(), title: Cow::Borrowed(title), body: Cow::Borrowed(body), buttons: &buttons, }; viewport.draw_component(0, 0, &dialog); } } struct Button<'a, Id> { id: Id, label: Cow<'a, str>, style: Style, is_focused: bool, } impl Button<'_, Id> { fn span(&self) -> Span { let Self { id: _, label, style, is_focused, } = self; if *is_focused { Span::styled(format!("({label})"), style.add_modifier(Modifier::REVERSED)) } else { Span::styled(format!("[{label}]"), *style) } } fn width(&self) -> usize { self.span().width() } } impl Component for Button<'_, Id> { type Id = Id; fn id(&self) -> Self::Id { self.id.clone() } fn draw(&self, viewport: &mut Viewport, x: isize, y: isize) { let span = self.span(); viewport.draw_span(x, y, &span); } } struct Dialog<'a, Id> { id: Id, title: Cow<'a, str>, body: Cow<'a, str>, buttons: &'a [Button<'a, Id>], } impl Component for Dialog<'_, Id> { type Id = Id; fn id(&self) -> Self::Id { self.id.clone() } fn draw(&self, viewport: &mut Viewport, _x: isize, _y: isize) { let Self { id: _, title, body, buttons, } = self; let rect = { let border_size = 2; let body_lines = body.lines().count(); let rect = centered_rect( viewport.rect(), RectSize { // FIXME: we might want to limit the width of the text and // let `Paragraph` wrap it. width: body.width() + border_size, height: body_lines + border_size, }, 60, 20, ); let paragraph = Paragraph::new(body.as_ref()).block( Block::default() .title(title.as_ref()) .borders(Borders::all()), ); let tui_rect = viewport.translate_rect(rect); viewport.draw_widget(tui_rect, Clear); viewport.draw_widget(tui_rect, paragraph); rect }; let mut bottom_x = rect.x + rect.width.unwrap_isize() - 1; let bottom_y = rect.y + rect.height.unwrap_isize() - 1; for button in buttons.iter() { bottom_x -= button.width().unwrap_isize(); let button_rect = viewport.draw_component(bottom_x, bottom_y, button); bottom_x = button_rect.x - 1; } } } fn highlight_rect(viewport: &mut Viewport, rect: Rect) { viewport.set_style(rect, Style::default().add_modifier(Modifier::REVERSED)); } #[cfg(test)] mod tests { use std::borrow::Cow; use crate::helpers::TestingInput; use super::*; use assert_matches::assert_matches; #[test] fn test_event_source_testing() { let mut event_source = TestingInput::new(80, 24, [Event::QuitCancel]); assert_matches!( event_source.next_events().unwrap().as_slice(), &[Event::QuitCancel] ); assert_matches!( event_source.next_events().unwrap().as_slice(), &[Event::None] ); } #[test] fn test_quit_returns_error() { let state = RecordState::default(); let mut input = TestingInput::new(80, 24, [Event::QuitCancel]); let recorder = Recorder::new(state, &mut input); assert_matches!(recorder.run(), Err(RecordError::Cancelled)); let state = RecordState { is_read_only: false, commits: vec![Commit::default(), Commit::default()], files: vec![File { old_path: None, path: Cow::Borrowed(Path::new("foo/bar")), file_mode: None, sections: Default::default(), }], }; let mut input = TestingInput::new(80, 24, [Event::QuitAccept]); let recorder = Recorder::new(state.clone(), &mut input); assert_eq!(recorder.run().unwrap(), state); } fn test_push_lines_from_span_impl(line: &str) { let mut spans = Vec::new(); push_spans_from_line(line, &mut spans); // assert no panic } proptest::proptest! { #[test] fn test_push_lines_from_span(line in ".*") { test_push_lines_from_span_impl(line.as_str()); } } } scm-record-0.5.0/src/util.rs000064400000000000000000000016761046102023000140100ustar 00000000000000pub trait UsizeExt { fn unwrap_isize(self) -> isize; fn clamp_into_u16(self) -> u16; } impl UsizeExt for usize { fn unwrap_isize(self) -> isize { isize::try_from(self).unwrap() } fn clamp_into_u16(self) -> u16 { if self > u16::MAX.into() { u16::MAX } else { self.try_into().unwrap() } } } pub trait IsizeExt { fn unwrap_usize(self) -> usize; #[allow(dead_code)] fn clamp_into_u16(self) -> u16; fn clamp_into_usize(self) -> usize; } impl IsizeExt for isize { fn unwrap_usize(self) -> usize { usize::try_from(self).unwrap() } fn clamp_into_u16(self) -> u16 { if self < 0 { 0 } else { self.try_into().unwrap_or(u16::MAX) } } fn clamp_into_usize(self) -> usize { if self < 0 { 0 } else { self.try_into().unwrap_or(usize::MAX) } } } scm-record-0.5.0/tests/example_contents.json000064400000000000000000000054071046102023000172770ustar 00000000000000{ "is_read_only": false, "commits": [], "files": [ { "path": "foo/bar", "file_mode": null, "sections": [ { "Unchanged": { "lines": [ "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n" ] } }, { "Changed": { "lines": [ { "is_checked": true, "change_type": "Removed", "line": "before text 1\n" }, { "is_checked": true, "change_type": "Removed", "line": "before text 2\n" }, { "is_checked": true, "change_type": "Added", "line": "after text 1\n" }, { "is_checked": false, "change_type": "Added", "line": "after text 2\n" } ] } }, { "Unchanged": { "lines": ["this is some trailing text\n"] } } ] }, { "path": "baz", "file_mode": null, "sections": [ { "Unchanged": { "lines": ["Some leading text 1\n", "Some leading text 2\n"] } }, { "Changed": { "lines": [ { "is_checked": true, "change_type": "Removed", "line": "before text 1\n" }, { "is_checked": true, "change_type": "Removed", "line": "before text 2\n" }, { "is_checked": true, "change_type": "Added", "line": "after text 1\n" }, { "is_checked": true, "change_type": "Added", "line": "after text 2\n" } ] } }, { "Unchanged": { "lines": ["this is some trailing text\n"] } } ] } ] } scm-record-0.5.0/tests/test_scm_record.rs000064400000000000000000005640601046102023000165660ustar 00000000000000use std::path::Path; use std::{borrow::Cow, iter}; use assert_matches::assert_matches; use insta::{assert_debug_snapshot, assert_snapshot}; use scm_record::helpers::{make_binary_description, TestingInput}; use scm_record::{ ChangeType, Commit, Event, File, FileMode, RecordError, RecordState, Recorder, Section, SectionChangedLine, TestingScreenshot, }; type TestResult = Result<(), scm_record::RecordError>; fn example_contents() -> RecordState<'static> { RecordState { is_read_only: false, commits: Default::default(), files: vec![ File { old_path: None, path: Cow::Borrowed(Path::new("foo/bar")), file_mode: None, sections: vec![ Section::Unchanged { lines: iter::repeat(Cow::Borrowed("this is some text\n")) .take(20) .collect(), }, Section::Changed { lines: vec![ SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("before text 1\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("before text 2\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Added, line: Cow::Borrowed("after text 1\n"), }, SectionChangedLine { is_checked: false, change_type: ChangeType::Added, line: Cow::Borrowed("after text 2\n"), }, ], }, Section::Unchanged { lines: vec![Cow::Borrowed("this is some trailing text\n")], }, ], }, File { old_path: None, path: Cow::Borrowed(Path::new("baz")), file_mode: None, sections: vec![ Section::Unchanged { lines: vec![ Cow::Borrowed("Some leading text 1\n"), Cow::Borrowed("Some leading text 2\n"), ], }, Section::Changed { lines: vec![ SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("before text 1\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("before text 2\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Added, line: Cow::Borrowed("after text 1\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Added, line: Cow::Borrowed("after text 2\n"), }, ], }, Section::Unchanged { lines: vec![Cow::Borrowed("this is some trailing text\n")], }, ], }, ], } } #[test] fn test_select_scroll_into_view() -> TestResult { let initial = TestingScreenshot::default(); let scroll_to_first_section = TestingScreenshot::default(); let scroll_to_second_file = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 6, [ Event::ExpandAll, initial.event(), // Scroll to first section (off-screen). Event::FocusNext, scroll_to_first_section.event(), // Scroll to second file (off-screen). It should display the entire // file contents, since they all fit in the viewport. Event::FocusNext, Event::FocusNext, Event::FocusNext, Event::FocusNext, Event::FocusNext, scroll_to_second_file.event(), Event::QuitAccept, ], ); let state = example_contents(); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " "###); insta::assert_snapshot!(scroll_to_first_section, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " (◐) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " "###); insta::assert_snapshot!(scroll_to_second_file, @r###" "[File] [Edit] [Select] [View] " "(●) baz (-)" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " "###); Ok(()) } #[test] fn test_toggle_all() -> TestResult { let before = TestingScreenshot::default(); let after = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 20, [ Event::ExpandAll, before.event(), Event::ToggleAll, after.event(), Event::QuitAccept, ], ); let state = example_contents(); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(before, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " "###); insta::assert_snapshot!(after, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [ ] - before text 1⏎ " " [ ] - before text 2⏎ " " [ ] + after text 1⏎ " " [●] + after text 2⏎ " " 23 this is some trailing text⏎ " "[ ] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [ ] Section 1/1 [-]" " [ ] - before text 1⏎ " " [ ] - before text 2⏎ " " [ ] + after text 1⏎ " " [ ] + after text 2⏎ " "###); Ok(()) } #[test] fn test_toggle_all_uniform() -> TestResult { let initial = TestingScreenshot::default(); let first_toggle = TestingScreenshot::default(); let second_toggle = TestingScreenshot::default(); let third_toggle = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 10, [ Event::ExpandAll, initial.event(), Event::ToggleAllUniform, first_toggle.event(), Event::ToggleAllUniform, second_toggle.event(), Event::ToggleAllUniform, third_toggle.event(), Event::QuitAccept, ], ); let state = example_contents(); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " "###); insta::assert_snapshot!(first_toggle, @r###" "[File] [Edit] [Select] [View] " "(●) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " "###); insta::assert_snapshot!(second_toggle, @r###" "[File] [Edit] [Select] [View] " "( ) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [ ] Section 1/1 [-]" " [ ] - before text 1⏎ " " [ ] - before text 2⏎ " " [ ] + after text 1⏎ " "###); insta::assert_snapshot!(third_toggle, @r###" "[File] [Edit] [Select] [View] " "(●) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " "###); Ok(()) } #[test] fn test_quit_dialog_size() -> TestResult { let expect_quit_dialog_to_be_centered = TestingScreenshot::default(); let mut input = TestingInput::new( 100, 40, [ Event::ExpandAll, Event::QuitInterrupt, expect_quit_dialog_to_be_centered.event(), Event::QuitInterrupt, ], ); let state = example_contents(); let recorder = Recorder::new(state, &mut input); let result = recorder.run(); assert_matches!(result, Err(RecordError::Cancelled)); insta::assert_snapshot!(expect_quit_dialog_to_be_centered, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before te┌Quit───────────────────────────────────────────────────────┐ " " [●] - before te│You have changes to 2 files. Are you sure you want to quit?│ " " [●] + after tex│ │ " " [●] + after tex│ │ " " 5 this is s│ │ " " │ │ " " │ │ " " └───────────────────────────────────────────[Go Back]─(Quit)┘ " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " "###); Ok(()) } #[test] fn test_quit_dialog_keyboard_navigation() -> TestResult { let expect_q_opens_quit_dialog = TestingScreenshot::default(); let expect_c_does_nothing = TestingScreenshot::default(); let expect_q_closes_quit_dialog = TestingScreenshot::default(); let expect_ctrl_c_opens_quit_dialog = TestingScreenshot::default(); let expect_exited = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 6, [ Event::ExpandAll, // Pressing 'q' should display the quit dialog. Event::QuitCancel, expect_q_opens_quit_dialog.event(), // Pressing 'c' now should do nothing. Event::QuitAccept, expect_c_does_nothing.event(), // Pressing 'q' now should close the quit dialog. Event::QuitCancel, expect_q_closes_quit_dialog.event(), // Pressing ctrl-c should display the quit dialog. Event::QuitInterrupt, expect_ctrl_c_opens_quit_dialog.event(), // Pressing ctrl-c again should exit. Event::QuitInterrupt, expect_exited.event(), ], ); let state = example_contents(); let recorder = Recorder::new(state, &mut input); assert_matches!(recorder.run(), Err(RecordError::Cancelled)); insta::assert_snapshot!(expect_q_opens_quit_dialog, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/b┌Quit───────────────────────────────────────────────────────┐ (-)" " ⋮│You have changes to 2 files. Are you sure you want to quit?│ " " 18└───────────────────────────────────────────[Go Back]─(Quit)┘ " " 19 this is some text⏎ " " 20 this is some text⏎ " "###); insta::assert_snapshot!(expect_c_does_nothing, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/b┌Quit───────────────────────────────────────────────────────┐ (-)" " ⋮│You have changes to 2 files. Are you sure you want to quit?│ " " 18└───────────────────────────────────────────[Go Back]─(Quit)┘ " " 19 this is some text⏎ " " 20 this is some text⏎ " "###); insta::assert_snapshot!(expect_q_closes_quit_dialog, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " "###); insta::assert_snapshot!(expect_ctrl_c_opens_quit_dialog, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/b┌Quit───────────────────────────────────────────────────────┐ (-)" " ⋮│You have changes to 2 files. Are you sure you want to quit?│ " " 18└───────────────────────────────────────────[Go Back]─(Quit)┘ " " 19 this is some text⏎ " " 20 this is some text⏎ " "###); insta::assert_snapshot!(expect_exited, @""); Ok(()) } #[test] fn test_quit_dialog_buttons() -> TestResult { let expect_quit_button_focused_initially = TestingScreenshot::default(); let expect_left_focuses_go_back_button = TestingScreenshot::default(); let expect_left_again_does_not_wrap = TestingScreenshot::default(); let expect_back_button_closes_quit_dialog = TestingScreenshot::default(); let expect_right_focuses_quit_button = TestingScreenshot::default(); let expect_right_again_does_not_wrap = TestingScreenshot::default(); let expect_ctrl_left_focuses_go_back_button = TestingScreenshot::default(); let expect_exited = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 6, [ Event::ExpandAll, Event::QuitCancel, expect_quit_button_focused_initially.event(), // Pressing left should select the back button. Event::FocusOuter { fold_section: true }, expect_left_focuses_go_back_button.event(), // Pressing left again should do nothing. Event::FocusOuter { fold_section: true }, expect_left_again_does_not_wrap.event(), // Selecting the back button should close the dialog. Event::ToggleItem, expect_back_button_closes_quit_dialog.event(), Event::QuitCancel, // Pressing right should select the quit button. Event::FocusOuter { fold_section: true }, Event::FocusInner, expect_right_focuses_quit_button.event(), // Pressing right again should do nothing. Event::FocusInner, expect_right_again_does_not_wrap.event(), // We have two ways to focus outer, with and without folding. // Both should navigate properly in this menu. Event::FocusOuter { fold_section: false, }, expect_ctrl_left_focuses_go_back_button.event(), // Selecting the quit button should quit. Event::FocusInner, Event::ToggleItem, expect_exited.event(), ], ); let state = example_contents(); let recorder = Recorder::new(state, &mut input); assert_matches!(recorder.run(), Err(RecordError::Cancelled)); insta::assert_snapshot!(expect_quit_button_focused_initially, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/b┌Quit───────────────────────────────────────────────────────┐ (-)" " ⋮│You have changes to 2 files. Are you sure you want to quit?│ " " 18└───────────────────────────────────────────[Go Back]─(Quit)┘ " " 19 this is some text⏎ " " 20 this is some text⏎ " "###); insta::assert_snapshot!(expect_left_focuses_go_back_button, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/b┌Quit───────────────────────────────────────────────────────┐ (-)" " ⋮│You have changes to 2 files. Are you sure you want to quit?│ " " 18└───────────────────────────────────────────(Go Back)─[Quit]┘ " " 19 this is some text⏎ " " 20 this is some text⏎ " "###); insta::assert_snapshot!(expect_left_again_does_not_wrap, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/b┌Quit───────────────────────────────────────────────────────┐ (-)" " ⋮│You have changes to 2 files. Are you sure you want to quit?│ " " 18└───────────────────────────────────────────(Go Back)─[Quit]┘ " " 19 this is some text⏎ " " 20 this is some text⏎ " "###); insta::assert_snapshot!(expect_back_button_closes_quit_dialog, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " "###); insta::assert_snapshot!(expect_right_focuses_quit_button, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/b┌Quit───────────────────────────────────────────────────────┐ (-)" " ⋮│You have changes to 2 files. Are you sure you want to quit?│ " " 18└───────────────────────────────────────────[Go Back]─(Quit)┘ " " 19 this is some text⏎ " " 20 this is some text⏎ " "###); insta::assert_snapshot!(expect_right_again_does_not_wrap, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/b┌Quit───────────────────────────────────────────────────────┐ (-)" " ⋮│You have changes to 2 files. Are you sure you want to quit?│ " " 18└───────────────────────────────────────────[Go Back]─(Quit)┘ " " 19 this is some text⏎ " " 20 this is some text⏎ " "###); insta::assert_snapshot!(expect_ctrl_left_focuses_go_back_button, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/b┌Quit───────────────────────────────────────────────────────┐ (-)" " ⋮│You have changes to 2 files. Are you sure you want to quit?│ " " 18└───────────────────────────────────────────(Go Back)─[Quit]┘ " " 19 this is some text⏎ " " 20 this is some text⏎ " "###); insta::assert_snapshot!(expect_exited, @""); Ok(()) } #[test] fn test_enter_next() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![ File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![Section::Changed { lines: vec![ SectionChangedLine { is_checked: false, change_type: ChangeType::Added, line: Cow::Borrowed("world\n"), }, SectionChangedLine { is_checked: false, change_type: ChangeType::Removed, line: Cow::Borrowed("hello\n"), }, ], }], }, File { old_path: None, path: Cow::Borrowed(Path::new("bar")), file_mode: None, sections: vec![Section::Changed { lines: vec![ SectionChangedLine { is_checked: false, change_type: ChangeType::Added, line: Cow::Borrowed("world\n"), }, SectionChangedLine { is_checked: false, change_type: ChangeType::Removed, line: Cow::Borrowed("hello\n"), }, ], }], }, ], }; let first_file_selected = TestingScreenshot::default(); let second_file_selected = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 7, [ Event::ExpandAll, Event::ToggleItemAndAdvance, first_file_selected.event(), Event::ToggleItemAndAdvance, second_file_selected.event(), Event::QuitCancel, Event::ToggleItemAndAdvance, ], ); let recorder = Recorder::new(state, &mut input); assert_matches!(recorder.run(), Err(RecordError::Cancelled)); insta::assert_snapshot!(first_file_selected, @r###" "[File] [Edit] [Select] [View] " "[●] foo [-]" " [●] - hello⏎ " "( ) bar (-)" " [ ] Section 1/1 [-]" " [ ] + world⏎ " " [ ] - hello⏎ " "###); insta::assert_snapshot!(second_file_selected, @r###" "[File] [Edit] [Select] [View] " "[●] foo [-]" " [●] - hello⏎ " "(●) bar (-)" " [●] Section 1/1 [-]" " [●] + world⏎ " " [●] - hello⏎ " "###); Ok(()) } #[test] fn test_file_mode_change() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![ File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![], }, File { old_path: None, path: Cow::Borrowed(Path::new("bar")), file_mode: None, sections: vec![Section::FileMode { is_checked: false, before: FileMode(0o100644), after: FileMode(0o100755), }], }, File { old_path: None, path: Cow::Borrowed(Path::new("qux")), file_mode: None, sections: vec![], }, ], }; let before_toggle = TestingScreenshot::default(); let after_toggle = TestingScreenshot::default(); let expect_no_crash = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 6, [ Event::ExpandAll, before_toggle.event(), Event::FocusNext, Event::FocusNext, Event::ToggleItem, after_toggle.event(), Event::FocusNext, expect_no_crash.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); insta::assert_debug_snapshot!(recorder.run()?, @r###" RecordState { is_read_only: false, commits: [ Commit { message: None, }, Commit { message: None, }, ], files: [ File { old_path: None, path: "foo", file_mode: None, sections: [], }, File { old_path: None, path: "bar", file_mode: None, sections: [ FileMode { is_checked: true, before: FileMode( 33188, ), after: FileMode( 33261, ), }, ], }, File { old_path: None, path: "qux", file_mode: None, sections: [], }, ], } "###); insta::assert_snapshot!(before_toggle, @r###" "[File] [Edit] [Select] [View] " "( ) foo (-)" "[ ] bar [-]" " [ ] File mode changed from 100644 to 100755 " "[ ] qux [-]" " " "###); insta::assert_snapshot!(after_toggle, @r###" "[File] [Edit] [Select] [View] " "[ ] foo [-]" "[●] bar [-]" " (●) File mode changed from 100644 to 100755 " "[ ] qux [-]" " " "###); insta::assert_snapshot!(expect_no_crash, @r###" "[File] [Edit] [Select] [View] " "[ ] foo [-]" "[●] bar [-]" " [●] File mode changed from 100644 to 100755 " "( ) qux (-)" " " "###); Ok(()) } #[test] fn test_abbreviate_unchanged_sections() -> TestResult { let num_context_lines = 3; let section_length = num_context_lines * 2; let middle_length = section_length + 1; let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![ Section::Unchanged { lines: (1..=section_length) .map(|x| Cow::Owned(format!("start line {x}/{section_length}\n"))) .collect(), }, Section::Changed { lines: vec![SectionChangedLine { is_checked: false, change_type: ChangeType::Added, line: Cow::Borrowed("changed\n"), }], }, Section::Unchanged { lines: (1..=middle_length) .map(|x| Cow::Owned(format!("middle line {x}/{middle_length}\n"))) .collect(), }, Section::Changed { lines: vec![SectionChangedLine { is_checked: false, change_type: ChangeType::Added, line: Cow::Borrowed("changed\n"), }], }, Section::Unchanged { lines: (1..=section_length) .map(|x| Cow::Owned(format!("end line {x}/{section_length}\n"))) .collect(), }, ], }], }; let initial = TestingScreenshot::default(); let collapse_bottom = TestingScreenshot::default(); let collapse_top = TestingScreenshot::default(); let expand_bottom = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 24, [ Event::ExpandAll, initial.event(), Event::FocusNext, Event::FocusNext, Event::FocusNext, Event::ExpandItem, collapse_bottom.event(), Event::FocusPrev, Event::FocusPrev, Event::ExpandItem, collapse_top.event(), Event::FocusNext, Event::ExpandItem, expand_bottom.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "( ) foo (-)" " ⋮ " " 4 start line 4/6⏎ " " 5 start line 5/6⏎ " " 6 start line 6/6⏎ " " [ ] Section 1/2 [-]" " [ ] + changed⏎ " " 7 middle line 1/7⏎ " " 8 middle line 2/7⏎ " " 9 middle line 3/7⏎ " " ⋮ " " 11 middle line 5/7⏎ " " 12 middle line 6/7⏎ " " 13 middle line 7/7⏎ " " [ ] Section 2/2 [-]" " [ ] + changed⏎ " " 14 end line 1/6⏎ " " 15 end line 2/6⏎ " " 16 end line 3/6⏎ " " ⋮ " " " " " " " "###); // Unchanged sections are collapsed unless there's at least one changed // section expanded before or after them. insta::assert_snapshot!(collapse_bottom, @r###" "[File] [Edit] [Select] [View] " "[ ] foo [±]" " ⋮ " " 4 start line 4/6⏎ " " 5 start line 5/6⏎ " " 6 start line 6/6⏎ " " [ ] Section 1/2 [-]" " [ ] + changed⏎ " " 7 middle line 1/7⏎ " " 8 middle line 2/7⏎ " " 9 middle line 3/7⏎ " " ⋮ " " 11 middle line 5/7⏎ " " 12 middle line 6/7⏎ " " 13 middle line 7/7⏎ " " ( ) Section 2/2 (+)" " " " " " " " " " " " " " " " " "###); insta::assert_snapshot!(collapse_top, @r###" "[File] [Edit] [Select] [View] " "[ ] foo [±]" " ( ) Section 1/2 (+)" " [ ] Section 2/2 [+]" " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " " "###); insta::assert_snapshot!(expand_bottom, @r###" "[File] [Edit] [Select] [View] " "[ ] foo [±]" " [ ] Section 1/2 [+]" " 7 middle line 1/7⏎ " " 8 middle line 2/7⏎ " " 9 middle line 3/7⏎ " " ⋮ " " 11 middle line 5/7⏎ " " 12 middle line 6/7⏎ " " 13 middle line 7/7⏎ " " ( ) Section 2/2 (-)" " [ ] + changed⏎ " " 14 end line 1/6⏎ " " 15 end line 2/6⏎ " " 16 end line 3/6⏎ " " ⋮ " " " " " " " " " " " " " " " " " "###); Ok(()) } #[test] fn test_no_abbreviate_short_unchanged_sections() -> TestResult { let num_context_lines = 3; let section_length = num_context_lines - 1; let middle_length = num_context_lines * 2; let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![ Section::Unchanged { lines: (1..=section_length) .map(|x| Cow::Owned(format!("start line {x}/{section_length}\n"))) .collect(), }, Section::Changed { lines: vec![SectionChangedLine { is_checked: false, change_type: ChangeType::Added, line: Cow::Borrowed("changed\n"), }], }, Section::Unchanged { lines: (1..=middle_length) .map(|x| Cow::Owned(format!("middle line {x}/{middle_length}\n"))) .collect(), }, Section::Changed { lines: vec![SectionChangedLine { is_checked: false, change_type: ChangeType::Added, line: Cow::Borrowed("changed\n"), }], }, Section::Unchanged { lines: (1..=section_length) .map(|x| Cow::Owned(format!("end line {x}/{section_length}\n"))) .collect(), }, ], }], }; let screenshot = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 20, [Event::ExpandAll, screenshot.event(), Event::QuitAccept], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(screenshot, @r###" "[File] [Edit] [Select] [View] " "( ) foo (-)" " 1 start line 1/2⏎ " " 2 start line 2/2⏎ " " [ ] Section 1/2 [-]" " [ ] + changed⏎ " " 3 middle line 1/6⏎ " " 4 middle line 2/6⏎ " " 5 middle line 3/6⏎ " " 6 middle line 4/6⏎ " " 7 middle line 5/6⏎ " " 8 middle line 6/6⏎ " " [ ] Section 2/2 [-]" " [ ] + changed⏎ " " 9 end line 1/2⏎ " " 10 end line 2/2⏎ " " " " " " " " " "###); Ok(()) } #[test] fn test_record_binary_file() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![Section::Binary { is_checked: false, old_description: Some(Cow::Owned(make_binary_description("abc123", 123))), new_description: Some(Cow::Owned(make_binary_description("def456", 456))), }], }], }; let initial = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 6, [ Event::ExpandAll, initial.event(), Event::ToggleItem, Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); let state = recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "( ) foo (-)" " [ ] (binary contents: abc123 (123 bytes) -> def456 (456 bytes)) " " " " " " " "###); assert_debug_snapshot!(state, @r###" RecordState { is_read_only: false, commits: [ Commit { message: None, }, Commit { message: None, }, ], files: [ File { old_path: None, path: "foo", file_mode: None, sections: [ Binary { is_checked: true, old_description: Some( "abc123 (123 bytes)", ), new_description: Some( "def456 (456 bytes)", ), }, ], }, ], } "###); let (selected, unselected) = state.files[0].get_selected_contents(); assert_debug_snapshot!(selected, @r###" Binary { old_description: Some( "abc123 (123 bytes)", ), new_description: Some( "def456 (456 bytes)", ), } "###); assert_debug_snapshot!(unselected, @"Unchanged"); Ok(()) } #[test] fn test_record_binary_file_noop() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![Section::Binary { is_checked: false, old_description: Some(Cow::Owned(make_binary_description("abc123", 123))), new_description: Some(Cow::Owned(make_binary_description("def456", 456))), }], }], }; let initial = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 6, [Event::ExpandAll, initial.event(), Event::QuitAccept], ); let recorder = Recorder::new(state, &mut input); let state = recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "( ) foo (-)" " [ ] (binary contents: abc123 (123 bytes) -> def456 (456 bytes)) " " " " " " " "###); assert_debug_snapshot!(state, @r###" RecordState { is_read_only: false, commits: [ Commit { message: None, }, Commit { message: None, }, ], files: [ File { old_path: None, path: "foo", file_mode: None, sections: [ Binary { is_checked: false, old_description: Some( "abc123 (123 bytes)", ), new_description: Some( "def456 (456 bytes)", ), }, ], }, ], } "###); let (selected, unselected) = state.files[0].get_selected_contents(); assert_debug_snapshot!(selected, @"Unchanged"); assert_debug_snapshot!(unselected, @r###" Binary { old_description: Some( "abc123 (123 bytes)", ), new_description: Some( "def456 (456 bytes)", ), } "###); Ok(()) } #[test] fn test_state_binary_selected_contents() -> TestResult { let test = |is_checked, binary| { let file = File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![ Section::Changed { lines: vec![SectionChangedLine { is_checked, change_type: ChangeType::Removed, line: Cow::Borrowed("foo\n"), }], }, Section::Binary { is_checked: binary, old_description: Some(Cow::Owned(make_binary_description("abc123", 123))), new_description: Some(Cow::Owned(make_binary_description("def456", 456))), }, ], }; let selection = file.get_selected_contents(); format!("{selection:?}") }; assert_snapshot!(test(false, false), @r###"(Unchanged, Binary { old_description: Some("abc123 (123 bytes)"), new_description: Some("def456 (456 bytes)") })"###); // FIXME: should the selected contents be `Present { contents: "" }`? (Or // possibly `Absent`?) assert_snapshot!(test(true, false), @r###"(Unchanged, Binary { old_description: Some("abc123 (123 bytes)"), new_description: Some("def456 (456 bytes)") })"###); // NB: The result for this situation, where we've selected both a text and // binary segment for inclusion, is arbitrary. The caller should avoid // generating both kinds of sections in the same file (or we should improve // the UI to never allow selecting both). assert_snapshot!(test(false, true), @r###"(Binary { old_description: Some("abc123 (123 bytes)"), new_description: Some("def456 (456 bytes)") }, Unchanged)"###); assert_snapshot!(test(true, true), @r###"(Binary { old_description: Some("abc123 (123 bytes)"), new_description: Some("def456 (456 bytes)") }, Unchanged)"###); Ok(()) } #[test] fn test_mouse_support() -> TestResult { let state = example_contents(); let initial = TestingScreenshot::default(); let first_click = TestingScreenshot::default(); let click_scrolled_item = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 7, [ Event::ExpandAll, initial.event(), Event::Click { row: 6, column: 8 }, Event::EnsureSelectionInViewport, first_click.event(), Event::Click { row: 6, column: 8 }, Event::EnsureSelectionInViewport, click_scrolled_item.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" "###); insta::assert_snapshot!(first_click, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " (◐) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " "###); insta::assert_snapshot!(click_scrolled_item, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " ( ) + after text 2⏎ " "###); Ok(()) } #[test] fn test_mouse_click_checkbox() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![ File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![], }, File { old_path: None, path: Cow::Borrowed(Path::new("bar")), file_mode: None, sections: vec![Section::FileMode { is_checked: false, before: FileMode::absent(), after: FileMode(0o100644), }], }, ], }; let initial = TestingScreenshot::default(); let click_unselected_checkbox = TestingScreenshot::default(); let click_selected_checkbox = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 4, [ Event::ExpandAll, initial.event(), Event::Click { row: 2, column: 1 }, click_unselected_checkbox.event(), Event::Click { row: 2, column: 1 }, click_selected_checkbox.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "( ) foo (-)" "[ ] bar [-]" " [ ] File mode changed from 0 to 100644 " "###); insta::assert_snapshot!(click_unselected_checkbox, @r###" "[File] [Edit] [Select] [View] " "[ ] foo [-]" "( ) bar (-)" " [ ] File mode changed from 0 to 100644 " "###); insta::assert_snapshot!(click_selected_checkbox, @r###" "[File] [Edit] [Select] [View] " "[ ] foo [-]" "(●) bar (-)" " [●] File mode changed from 0 to 100644 " "###); Ok(()) } #[test] fn test_mouse_click_wide_line() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![ Section::FileMode { is_checked: false, before: FileMode::absent(), after: FileMode(0o100644), }, Section::Changed { lines: vec![SectionChangedLine { is_checked: false, change_type: ChangeType::Removed, line: Cow::Borrowed("foo\n"), }], }, ], }], }; let initial = TestingScreenshot::default(); let click_line = TestingScreenshot::default(); let click_line_section = TestingScreenshot::default(); let click_file_mode_section = TestingScreenshot::default(); let click_file = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 5, [ Event::ExpandAll, initial.event(), Event::Click { row: 4, column: 50 }, click_line.event(), Event::Click { row: 3, column: 50 }, click_line_section.event(), Event::Click { row: 2, column: 50 }, click_file_mode_section.event(), Event::Click { row: 1, column: 50 }, click_file.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "( ) foo (-)" " [ ] File mode changed from 0 to 100644 " " [ ] Section 2/2 [-]" " [ ] - foo⏎ " "###); insta::assert_snapshot!(click_line, @r###" "[File] [Edit] [Select] [View] " "[ ] foo [-]" " [ ] File mode changed from 0 to 100644 " " [ ] Section 2/2 [-]" " ( ) - foo⏎ " "###); insta::assert_snapshot!(click_line_section, @r###" "[File] [Edit] [Select] [View] " "[ ] foo [-]" " [ ] File mode changed from 0 to 100644 " " ( ) Section 2/2 (-)" " [ ] - foo⏎ " "###); insta::assert_snapshot!(click_file_mode_section, @r###" "[File] [Edit] [Select] [View] " "[ ] foo [-]" " ( ) File mode changed from 0 to 100644 " " [ ] Section 2/2 [-]" " [ ] - foo⏎ " "###); insta::assert_snapshot!(click_file, @r###" "[File] [Edit] [Select] [View] " "( ) foo (-)" " [ ] File mode changed from 0 to 100644 " " [ ] Section 2/2 [-]" " [ ] - foo⏎ " "###); Ok(()) } #[test] fn test_mouse_click_dialog_buttons() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![Section::Changed { lines: vec![SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("foo\n"), }], }], }], }; let click_nothing = TestingScreenshot::default(); let click_go_back = TestingScreenshot::default(); let events = [ Event::ExpandAll, Event::QuitCancel, Event::Click { row: 3, column: 55 }, click_nothing.event(), Event::QuitCancel, Event::Click { row: 3, column: 65 }, click_go_back.event(), ]; let mut input = TestingInput::new(80, 6, events); let recorder = Recorder::new(state, &mut input); let result = recorder.run(); insta::assert_debug_snapshot!(result, @r###" Err( Cancelled, ) "###); insta::assert_snapshot!(click_nothing, @r###" "[File] [Edit] [Select] [View] " "(●) foo (-)" " [●] Section 1/1 [-]" " [●] - foo⏎ " " " " " "###); insta::assert_snapshot!(click_go_back, @""); Ok(()) } #[test] fn test_render_old_path() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![File { old_path: Some(Cow::Borrowed(Path::new("foo"))), path: Cow::Borrowed(Path::new("bar")), file_mode: None, sections: vec![], }], }; let screenshot = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 6, [Event::ExpandAll, screenshot.event(), Event::QuitAccept], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(screenshot, @r###" "[File] [Edit] [Select] [View] " "( ) foo => bar (-)" " " " " " " " " "###); Ok(()) } #[test] fn test_expand() -> TestResult { let state = example_contents(); let initial = TestingScreenshot::default(); let after_expand = TestingScreenshot::default(); let after_collapse = TestingScreenshot::default(); let after_expand_mouse = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 7, [ initial.event(), Event::ExpandItem, after_expand.event(), Event::ExpandItem, after_collapse.event(), Event::Click { row: 2, column: 78 }, Event::Click { row: 2, column: 78 }, after_expand_mouse.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (+)" "[●] baz [+]" " " " " " " " " "###); insta::assert_snapshot!(after_expand, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" "###); insta::assert_snapshot!(after_collapse, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (+)" "[●] baz [+]" " " " " " " " " "###); insta::assert_snapshot!(after_expand_mouse, @r###" "[File] [Edit] [Select] [View] " "(●) baz (-)" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " "###); Ok(()) } #[test] fn test_expand_line_noop() -> TestResult { let state = example_contents(); let after_select = TestingScreenshot::default(); let after_expand_noop = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 7, [ Event::ExpandAll, Event::FocusNext, Event::FocusNext, after_select.event(), Event::ExpandItem, after_expand_noop.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(after_select, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " [◐] Section 1/1 [-]" " (●) - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " "###); insta::assert_snapshot!(after_expand_noop, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " [◐] Section 1/1 [-]" " (●) - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " "###); Ok(()) } #[test] fn test_expand_scroll_into_view() -> TestResult { let state = example_contents(); let before_expand = TestingScreenshot::default(); let after_expand = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 7, [ Event::FocusNext, before_expand.event(), Event::ExpandAll, after_expand.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(before_expand, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [+]" "(●) baz (+)" " " " " " " " " "###); insta::assert_snapshot!(after_expand, @r###" "[File] [Edit] [Select] [View] " "(●) baz (-)" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " "###); Ok(()) } #[test] fn test_collapse_select_ancestor() -> TestResult { let state = example_contents(); let before_collapse = TestingScreenshot::default(); let after_collapse = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 7, [ Event::ExpandAll, Event::FocusNext, before_collapse.event(), Event::ExpandAll, after_collapse.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(before_collapse, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " (◐) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " "###); insta::assert_snapshot!(after_collapse, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (+)" "[●] baz [+]" " " " " " " " " "###); Ok(()) } #[test] fn test_focus_inner() -> TestResult { let state = example_contents(); let initial = TestingScreenshot::default(); let inner1 = TestingScreenshot::default(); let inner2 = TestingScreenshot::default(); let inner3 = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 7, [ initial.event(), Event::FocusInner, inner1.event(), Event::FocusInner, inner2.event(), Event::FocusInner, inner3.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (+)" "[●] baz [+]" " " " " " " " " "###); insta::assert_snapshot!(inner1, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " (◐) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " "###); insta::assert_snapshot!(inner2, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " [◐] Section 1/1 [-]" " (●) - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " "###); insta::assert_snapshot!(inner3, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " [◐] Section 1/1 [-]" " (●) - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " "###); Ok(()) } #[test] fn test_focus_outer() -> TestResult { let state = example_contents(); let initial = TestingScreenshot::default(); let outer1 = TestingScreenshot::default(); let outer2 = TestingScreenshot::default(); let outer3 = TestingScreenshot::default(); let outer4 = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 7, [ Event::FocusNext, Event::ExpandItem, Event::FocusNext, Event::FocusNext, Event::FocusNext, initial.event(), Event::FocusOuter { fold_section: false, }, outer1.event(), Event::FocusOuter { fold_section: false, }, outer2.event(), Event::FocusOuter { fold_section: false, }, outer3.event(), Event::FocusOuter { fold_section: false, }, outer4.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "[●] baz [-]" " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " (●) - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " "###); insta::assert_snapshot!(outer1, @r###" "[File] [Edit] [Select] [View] " "[●] baz [-]" " (●) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " "###); insta::assert_snapshot!(outer2, @r###" "[File] [Edit] [Select] [View] " "(●) baz (-)" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " "###); insta::assert_snapshot!(outer3, @r###" "[File] [Edit] [Select] [View] " "(●) baz (+)" " " " " " " " " " " "###); insta::assert_snapshot!(outer4, @r###" "[File] [Edit] [Select] [View] " "(●) baz (+)" " " " " " " " " " " "###); Ok(()) } #[test] fn test_focus_outer_fold_section() -> TestResult { let state = example_contents(); let initial = TestingScreenshot::default(); let outer1 = TestingScreenshot::default(); let outer2 = TestingScreenshot::default(); let outer3 = TestingScreenshot::default(); let outer4 = TestingScreenshot::default(); let outer5 = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 7, [ Event::FocusNext, Event::ExpandItem, Event::FocusNext, Event::FocusNext, Event::FocusNext, initial.event(), Event::FocusOuter { fold_section: true }, outer1.event(), Event::FocusOuter { fold_section: true }, outer2.event(), Event::FocusOuter { fold_section: true }, outer3.event(), Event::FocusOuter { fold_section: true }, outer4.event(), Event::FocusOuter { fold_section: true }, outer5.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "[●] baz [-]" " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " (●) - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " "###); insta::assert_snapshot!(outer1, @r###" "[File] [Edit] [Select] [View] " "[●] baz [-]" " (●) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " "###); insta::assert_snapshot!(outer2, @r###" "[File] [Edit] [Select] [View] " "[●] baz [±]" " (●) Section 1/1 (+)" " " " " " " " " "###); insta::assert_snapshot!(outer3, @r###" "[File] [Edit] [Select] [View] " "(●) baz (±)" " [●] Section 1/1 [+]" " " " " " " " " "###); insta::assert_snapshot!(outer4, @r###" "[File] [Edit] [Select] [View] " "(●) baz (+)" " " " " " " " " " " "###); insta::assert_snapshot!(outer5, @r###" "[File] [Edit] [Select] [View] " "(●) baz (+)" " " " " " " " " " " "###); Ok(()) } #[test] fn test_sticky_header_scroll() -> TestResult { let state = example_contents(); let initial = TestingScreenshot::default(); let scroll1 = TestingScreenshot::default(); let scroll2 = TestingScreenshot::default(); let scroll3 = TestingScreenshot::default(); let scroll4 = TestingScreenshot::default(); let scroll5 = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 7, [ Event::ExpandAll, initial.event(), Event::ScrollDown, scroll1.event(), Event::ScrollDown, scroll2.event(), Event::ScrollDown, scroll3.event(), Event::ScrollDown, scroll4.event(), Event::ScrollDown, scroll5.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" "###); insta::assert_snapshot!(scroll1, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " "###); insta::assert_snapshot!(scroll2, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " "###); insta::assert_snapshot!(scroll3, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " "###); insta::assert_snapshot!(scroll4, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " "###); insta::assert_snapshot!(scroll5, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "###); Ok(()) } #[test] fn test_sticky_header_click_expand() -> TestResult { let state = example_contents(); let initial = TestingScreenshot::default(); let after_scroll = TestingScreenshot::default(); let after_click1 = TestingScreenshot::default(); let after_click2 = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 7, [ initial.event(), Event::FocusNext, Event::ExpandItem, Event::FocusNext, after_scroll.event(), Event::Click { row: 1, column: 70 }, after_click1.event(), Event::Click { row: 1, column: 78 }, after_click2.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (+)" "[●] baz [+]" " " " " " " " " "###); insta::assert_snapshot!(after_scroll, @r###" "[File] [Edit] [Select] [View] " "[●] baz [-]" " (●) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " "###); insta::assert_snapshot!(after_click1, @r###" "[File] [Edit] [Select] [View] " "(●) baz (-)" " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " "###); insta::assert_snapshot!(after_click2, @r###" "[File] [Edit] [Select] [View] " "(●) baz (+)" " " " " " " " " " " "###); Ok(()) } #[test] fn test_scroll_click_no_jump() -> TestResult { let state = example_contents(); let initial = TestingScreenshot::default(); let after_click = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 7, [ Event::ExpandAll, initial.event(), Event::Click { row: 5, column: 5 }, after_click.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" "###); insta::assert_snapshot!(after_click, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" "###); Ok(()) } #[test] fn test_menu_bar_scroll_into_view() -> TestResult { let state = example_contents(); let initial = TestingScreenshot::default(); let after_scroll1 = TestingScreenshot::default(); let after_scroll2 = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 6, [ initial.event(), Event::ScrollDown, after_scroll1.event(), Event::ScrollDown, after_scroll2.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (+)" "[●] baz [+]" " " " " " " "###); insta::assert_snapshot!(after_scroll1, @r###" "[File] [Edit] [Select] [View] " "[●] baz [+]" " " " " " " " " "###); insta::assert_snapshot!(after_scroll2, @r###" "[File] [Edit] [Select] [View] " "[●] baz [+]" " " " " " " " " "###); Ok(()) } #[test] fn test_expand_menu() -> TestResult { let state = example_contents(); let initial = TestingScreenshot::default(); let after_click = TestingScreenshot::default(); let after_click_different = TestingScreenshot::default(); let after_click_same = TestingScreenshot::default(); let after_click_menu_bar = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 6, [ initial.event(), Event::Click { row: 0, column: 8 }, after_click.event(), Event::Click { row: 0, column: 0 }, after_click_different.event(), Event::Click { row: 0, column: 0 }, after_click_same.event(), Event::Click { row: 0, column: 79 }, after_click_menu_bar.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (+)" "[●] baz [+]" " " " " " " "###); insta::assert_snapshot!(after_click, @r###" "[File] [Edit] [Select] [View] " "(◐) foo[Edit message (e)] (+)" "[●] baz[Toggle current (space)] [+]" " [Toggle current and advance (enter)] " " [Invert all items (a)] " " [Invert all items uniformly (A)] " "###); insta::assert_snapshot!(after_click_different, @r###" "[File] [Edit] [Select] [View] " "[Confirm (c)] (+)" "[Quit (q)] [+]" " " " " " " "###); insta::assert_snapshot!(after_click_same, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (+)" "[●] baz [+]" " " " " " " "###); insta::assert_snapshot!(after_click_menu_bar, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (+)" "[●] baz [+]" " " " " " " "###); Ok(()) } #[test] fn test_read_only() -> TestResult { let state = RecordState { is_read_only: true, ..example_contents() }; let initial = TestingScreenshot::default(); let after_toggle_all_ignored = TestingScreenshot::default(); let after_toggle_all_uniform_ignored = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 23, [ Event::ExpandAll, initial.event(), Event::ToggleAll, after_toggle_all_ignored.event(), Event::ToggleAllUniform, after_toggle_all_uniform_ignored.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); let state = recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "<◐> foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " <◐> Section 1/1 [-]" " <●> - before text 1⏎ " " <●> - before text 2⏎ " " <●> + after text 1⏎ " " < > + after text 2⏎ " " 23 this is some trailing text⏎ " "<●> baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " <●> Section 1/1 [-]" " <●> - before text 1⏎ " " <●> - before text 2⏎ " " <●> + after text 1⏎ " " <●> + after text 2⏎ " " 5 this is some trailing text⏎ " " " " " "###); insta::assert_snapshot!(after_toggle_all_ignored, @r###" "[File] [Edit] [Select] [View] " "<◐> foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " <◐> Section 1/1 [-]" " <●> - before text 1⏎ " " <●> - before text 2⏎ " " <●> + after text 1⏎ " " < > + after text 2⏎ " " 23 this is some trailing text⏎ " "<●> baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " <●> Section 1/1 [-]" " <●> - before text 1⏎ " " <●> - before text 2⏎ " " <●> + after text 1⏎ " " <●> + after text 2⏎ " " 5 this is some trailing text⏎ " " " " " "###); insta::assert_snapshot!(after_toggle_all_uniform_ignored, @r###" "[File] [Edit] [Select] [View] " "<◐> foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " <◐> Section 1/1 [-]" " <●> - before text 1⏎ " " <●> - before text 2⏎ " " <●> + after text 1⏎ " " < > + after text 2⏎ " " 23 this is some trailing text⏎ " "<●> baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " <●> Section 1/1 [-]" " <●> - before text 1⏎ " " <●> - before text 2⏎ " " <●> + after text 1⏎ " " <●> + after text 2⏎ " " 5 this is some trailing text⏎ " " " " " "###); insta::assert_debug_snapshot!(state, @r###" RecordState { is_read_only: true, commits: [ Commit { message: None, }, Commit { message: None, }, ], files: [ File { old_path: None, path: "foo/bar", file_mode: None, sections: [ Unchanged { lines: [ "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", ], }, Changed { lines: [ SectionChangedLine { is_checked: true, change_type: Removed, line: "before text 1\n", }, SectionChangedLine { is_checked: true, change_type: Removed, line: "before text 2\n", }, SectionChangedLine { is_checked: true, change_type: Added, line: "after text 1\n", }, SectionChangedLine { is_checked: false, change_type: Added, line: "after text 2\n", }, ], }, Unchanged { lines: [ "this is some trailing text\n", ], }, ], }, File { old_path: None, path: "baz", file_mode: None, sections: [ Unchanged { lines: [ "Some leading text 1\n", "Some leading text 2\n", ], }, Changed { lines: [ SectionChangedLine { is_checked: true, change_type: Removed, line: "before text 1\n", }, SectionChangedLine { is_checked: true, change_type: Removed, line: "before text 2\n", }, SectionChangedLine { is_checked: true, change_type: Added, line: "after text 1\n", }, SectionChangedLine { is_checked: true, change_type: Added, line: "after text 2\n", }, ], }, Unchanged { lines: [ "this is some trailing text\n", ], }, ], }, ], } "###); Ok(()) } #[test] fn test_toggle_unchanged_line() -> TestResult { let state = example_contents(); let initial = TestingScreenshot::default(); let after_toggle = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 6, [ Event::ExpandAll, initial.event(), Event::Click { row: 4, column: 10 }, Event::ToggleItem, // should not crash after_toggle.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); let state = recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " "###); insta::assert_debug_snapshot!(state, @r###" RecordState { is_read_only: false, commits: [ Commit { message: None, }, Commit { message: None, }, ], files: [ File { old_path: None, path: "foo/bar", file_mode: None, sections: [ Unchanged { lines: [ "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", "this is some text\n", ], }, Changed { lines: [ SectionChangedLine { is_checked: true, change_type: Removed, line: "before text 1\n", }, SectionChangedLine { is_checked: true, change_type: Removed, line: "before text 2\n", }, SectionChangedLine { is_checked: true, change_type: Added, line: "after text 1\n", }, SectionChangedLine { is_checked: false, change_type: Added, line: "after text 2\n", }, ], }, Unchanged { lines: [ "this is some trailing text\n", ], }, ], }, File { old_path: None, path: "baz", file_mode: None, sections: [ Unchanged { lines: [ "Some leading text 1\n", "Some leading text 2\n", ], }, Changed { lines: [ SectionChangedLine { is_checked: true, change_type: Removed, line: "before text 1\n", }, SectionChangedLine { is_checked: true, change_type: Removed, line: "before text 2\n", }, SectionChangedLine { is_checked: true, change_type: Added, line: "after text 1\n", }, SectionChangedLine { is_checked: true, change_type: Added, line: "after text 2\n", }, ], }, Unchanged { lines: [ "this is some trailing text\n", ], }, ], }, ], } "###); Ok(()) } #[test] fn test_max_file_view_width() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![File { old_path: None, path: Cow::Owned("very/".repeat(100).into()), file_mode: None, sections: vec![ Section::Unchanged { lines: vec![Cow::Owned("very ".repeat(100))], }, Section::Changed { lines: vec![SectionChangedLine { is_checked: false, change_type: ChangeType::Added, line: Cow::Owned("very ".repeat(100)), }], }, ], }], }; let initial_wide = TestingScreenshot::default(); let mut input = TestingInput::new( 250, 6, [ Event::ExpandAll, Event::ToggleCommitViewMode, initial_wide.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state.clone(), &mut input); recorder.run()?; insta::assert_snapshot!(initial_wide, @r###" "[File] [Edit] [Select] [View] " "( ) very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/ve…(-) [ ] very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/ve…[+] " " 1 very very very very very very very very very very very very very very very very very very very very very very… " " [ ] Section 1/1 [-] " " [ ] + very very very very very very very very very very very very very very very very very very very very very very… " " " "###); let initial_narrow = TestingScreenshot::default(); let mut input = TestingInput::new( 15, 6, [Event::ExpandAll, initial_narrow.event(), Event::QuitAccept], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial_narrow, @r###" "[File] [Edit] [" "( ) very/ve…(-)" " 1 very…" " [ ] Secti…[-]" " [ ] + very…" " " "###); Ok(()) } #[test] fn test_commit_message_view() -> TestResult { let mut state = example_contents(); state.commits = vec![Commit { message: Some("".to_string()), }]; let initial = TestingScreenshot::default(); let after_edit = TestingScreenshot::default(); let after_scroll1 = TestingScreenshot::default(); let after_scroll2 = TestingScreenshot::default(); let mut input = TestingInput { width: 80, height: 24, events: Box::new( [ Event::ExpandAll, initial.event(), Event::EditCommitMessage, after_edit.event(), Event::ScrollDown, after_scroll1.event(), Event::ScrollDown, Event::ScrollDown, Event::ScrollDown, after_scroll2.event(), Event::QuitAccept, ] .into_iter(), ), commit_messages: ["Hello, world!".to_string()].into_iter().collect(), }; let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " " " "[Edit message] • (no message) " " " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " " 5 this is some trailing text⏎ " "###); insta::assert_snapshot!(after_edit, @r###" "[File] [Edit] [Select] [View] " " " "[Edit message] • Hello, world! " " " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " " 5 this is some trailing text⏎ " "###); insta::assert_snapshot!(after_scroll1, @r###" "[File] [Edit] [Select] [View] " "[Edit message] • Hello, world! " " " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " " 5 this is some trailing text⏎ " " " "###); insta::assert_snapshot!(after_scroll2, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " " 5 this is some trailing text⏎ " " " " " " " " " "###); Ok(()) } #[test] fn test_quit_dialog_when_commit_message_provided() -> TestResult { let mut state = example_contents(); state.commits = vec![Commit { message: Some("hello".to_string()), }]; let changed_message_and_files = TestingScreenshot::default(); let changed_message_only = TestingScreenshot::default(); let mut input = TestingInput { width: 80, height: 24, events: Box::new( [ Event::QuitInterrupt, changed_message_and_files.event(), Event::QuitCancel, Event::ToggleAllUniform, // toggle all Event::ToggleAllUniform, // toggle none Event::QuitInterrupt, changed_message_only.event(), Event::QuitInterrupt, ] .into_iter(), ), commit_messages: [].into_iter().collect(), }; let recorder = Recorder::new(state, &mut input); assert_matches!(recorder.run(), Err(RecordError::Cancelled)); insta::assert_snapshot!(changed_message_and_files, @r###" "[File] [Edit] [Select] [View] " " " "[Edit message] • hello " " " "(◐) foo/bar (+)" "[●] baz [+]" " " " " " " " ┌Quit─────────────────────────────────────────────────────────────────────┐ " " │You have changes to 1 message and 2 files. Are you sure you want to quit?│ " " │ │ " " └─────────────────────────────────────────────────────────[Go Back]─(Quit)┘ " " " " " " " " " " " " " " " " " " " " " " " "###); insta::assert_snapshot!(changed_message_only, @r###" "[File] [Edit] [Select] [View] " " " "[Edit message] • hello " " " "( ) foo/bar (+)" "[ ] baz [+]" " " " " " " " ┌Quit─────────────────────────────────────────────────────────┐ " " │You have changes to 1 message. Are you sure you want to quit?│ " " │ │ " " └─────────────────────────────────────────────[Go Back]─(Quit)┘ " " " " " " " " " " " " " " " " " " " " " " " "###); Ok(()) } #[test] fn test_prev_same_kind() -> TestResult { let initial = TestingScreenshot::default(); let to_baz = TestingScreenshot::default(); let to_baz_section = TestingScreenshot::default(); let to_bar_section = TestingScreenshot::default(); let to_baz_lines = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 20, [ Event::ExpandAll, initial.event(), // Moves the current item from foo/bar to baz Event::FocusPrevSameKind, to_baz.event(), Event::FocusInner, to_baz_section.event(), Event::FocusPrevSameKind, to_bar_section.event(), Event::FocusInner, Event::FocusPrevSameKind, Event::FocusPrevSameKind, to_baz_lines.event(), Event::QuitAccept, ], ); let state = example_contents(); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " "###); insta::assert_snapshot!(to_baz, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "(●) baz (-)" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " " 5 this is some trailing text⏎ " "###); insta::assert_snapshot!(to_baz_section, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " (●) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " " 5 this is some trailing text⏎ " "###); insta::assert_snapshot!(to_bar_section, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " (◐) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " " 5 this is some trailing text⏎ " "###); insta::assert_snapshot!(to_baz_lines, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " (●) + after text 1⏎ " " [●] + after text 2⏎ " " 5 this is some trailing text⏎ " "###); Ok(()) } #[test] fn test_next_same_kind() -> TestResult { let initial = TestingScreenshot::default(); let to_baz = TestingScreenshot::default(); let to_baz_section = TestingScreenshot::default(); let to_bar_section = TestingScreenshot::default(); let to_bar_lines = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 20, [ Event::ExpandAll, initial.event(), // Moves the current item from foo/bar to baz Event::FocusNextSameKind, to_baz.event(), Event::FocusInner, to_baz_section.event(), Event::FocusNextSameKind, to_bar_section.event(), Event::FocusInner, Event::FocusNextSameKind, Event::FocusNextSameKind, to_bar_lines.event(), Event::QuitAccept, ], ); let state = example_contents(); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(◐) foo/bar (-)" " ⋮ " " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " "###); insta::assert_snapshot!(to_baz, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "(●) baz (-)" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " " 5 this is some trailing text⏎ " "###); insta::assert_snapshot!(to_baz_section, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " (●) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " " 5 this is some trailing text⏎ " "###); insta::assert_snapshot!(to_bar_section, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " (◐) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " " 5 this is some trailing text⏎ " "###); insta::assert_snapshot!(to_bar_lines, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " [◐] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " (●) + after text 1⏎ " " [ ] + after text 2⏎ " " 23 this is some trailing text⏎ " "[●] baz [-]" " 1 Some leading text 1⏎ " " 2 Some leading text 2⏎ " " [●] Section 1/1 [-]" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [●] + after text 2⏎ " " 5 this is some trailing text⏎ " "###); Ok(()) } // Test the prev/next same kind keybindings when there is only a single section // of a given kind. #[test] fn test_prev_next_same_kind_single_section() -> TestResult { let initial = TestingScreenshot::default(); let next = TestingScreenshot::default(); let prev = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 10, [ Event::ExpandAll, // Move down to the section so the current selection isn't the // first item. Event::FocusNext, initial.event(), // Moves the current item from foo/bar to baz Event::FocusNextSameKind, next.event(), Event::FocusPrevSameKind, prev.event(), Event::QuitAccept, ], ); let mut state = example_contents(); // Change the example so that there's only a single file. state.files = vec![state.files[0].clone()]; let recorder = Recorder::new(state, &mut input); recorder.run()?; // Since we start at the foo/bar file section and there are no other // sections, the current section never changes. insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " (◐) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " "###); insta::assert_snapshot!(next, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " (◐) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " "###); insta::assert_snapshot!(prev, @r###" "[File] [Edit] [Select] [View] " "[◐] foo/bar [-]" " 18 this is some text⏎ " " 19 this is some text⏎ " " 20 this is some text⏎ " " (◐) Section 1/1 (-)" " [●] - before text 1⏎ " " [●] - before text 2⏎ " " [●] + after text 1⏎ " " [ ] + after text 2⏎ " "###); Ok(()) } #[cfg(feature = "serde")] #[test] fn test_deserialize() -> TestResult { let example_json = include_str!("example_contents.json"); let deserialized: RecordState<'static> = serde_json::from_str(example_json).unwrap(); assert_eq!(example_contents(), deserialized); Ok(()) } #[test] fn test_no_files() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![], }; let initial = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 6, [Event::ExpandAll, initial.event(), Event::QuitAccept], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " " " " There are no changes to view. " " " " " " " "###); Ok(()) } #[test] fn test_tabs_in_files() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![File { old_path: None, path: Cow::Borrowed(Path::new("foo/bar")), file_mode: None, sections: vec![ Section::Unchanged { lines: iter::repeat(Cow::Borrowed("\tthis is some indented text\n")) .take(10) .collect(), }, Section::Changed { lines: vec![ SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("before text\t1\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Added, line: Cow::Borrowed("after text 1\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("before text 2\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Added, line: Cow::Borrowed("after text\t2\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("\tbefore text 3\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Added, line: Cow::Borrowed("\tafter text\t3\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("\tbefore text\t4\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Added, line: Cow::Borrowed("\tafter text 4\n"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Removed, line: Cow::Borrowed("\tbefore text\t5"), }, SectionChangedLine { is_checked: true, change_type: ChangeType::Added, line: Cow::Borrowed("\tafter text\t5"), }, ], }, Section::Unchanged { lines: vec![Cow::Borrowed("this is some trailing\ttext\n")], }, ], }], }; let initial = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 18, [Event::ExpandAll, initial.event(), Event::QuitAccept], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "(●) foo/bar (-)" " ⋮ " " 8 → this is some indented text⏎ " " 9 → this is some indented text⏎ " " 10 → this is some indented text⏎ " " [●] Section 1/1 [-]" " [●] - before text→ 1⏎ " " [●] + after text 1⏎ " " [●] - before text 2⏎ " " [●] + after text→ 2⏎ " " [●] - → before text 3⏎ " " [●] + → after text→ 3⏎ " " [●] - → before text→ 4⏎ " " [●] + → after text 4⏎ " " [●] - → before text→ 5 " " [●] + → after text→ 5 " " 16 this is some trailing→ text⏎ " "###); Ok(()) } #[test] fn test_carriage_return() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![Section::Changed { lines: vec![ SectionChangedLine { is_checked: false, change_type: ChangeType::Removed, line: Cow::Borrowed("before text\n"), }, SectionChangedLine { is_checked: false, change_type: ChangeType::Added, line: Cow::Borrowed("before text\r\n"), }, ], }], }], }; let initial = TestingScreenshot::default(); let focus = TestingScreenshot::default(); let unfocus = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 8, [ Event::ExpandAll, initial.event(), Event::FocusNext, Event::FocusNext, focus.event(), Event::FocusPrev, Event::FocusPrev, unfocus.event(), Event::QuitAccept, ], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "( ) foo (-)" " [ ] Section 1/1 [-]" " [ ] - before text⏎ " " [ ] + before text␍⏎ " " " " " " " "###); insta::assert_snapshot!(focus, @r###" "[File] [Edit] [Select] [View] " "[ ] foo [-]" " [ ] Section 1/1 [-]" " ( ) - before text⏎ " " [ ] + before text␍⏎ " " " " " " " "###); insta::assert_snapshot!(unfocus, @r###" "[File] [Edit] [Select] [View] " "( ) foo (-)" " [ ] Section 1/1 [-]" " [ ] - before text⏎ " " [ ] + before text␍⏎ " " " " " " " "###); Ok(()) } #[test] fn test_some_control_characters() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![Section::Changed { lines: vec![SectionChangedLine { is_checked: false, change_type: ChangeType::Added, line: Cow::Borrowed("nul:\0, bel:\x07, esc:\x1b, del:\x7f\n"), }], }], }], }; let initial = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 8, [Event::ExpandAll, initial.event(), Event::QuitAccept], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "( ) foo (-)" " [ ] Section 1/1 [-]" " [ ] + nul:␀, bel:␇, esc:␛, del:␡⏎ " " " " " " " " " "###); Ok(()) } #[test] fn test_non_printing_characters() -> TestResult { let state = RecordState { is_read_only: false, commits: Default::default(), files: vec![File { old_path: None, path: Cow::Borrowed(Path::new("foo")), file_mode: None, sections: vec![Section::Changed { lines: vec![SectionChangedLine { is_checked: false, change_type: ChangeType::Added, line: Cow::Borrowed("zwj:\u{200d}, zwnj:\u{200c}"), }], }], }], }; let initial = TestingScreenshot::default(); let mut input = TestingInput::new( 80, 8, [Event::ExpandAll, initial.event(), Event::QuitAccept], ); let recorder = Recorder::new(state, &mut input); recorder.run()?; insta::assert_snapshot!(initial, @r###" "[File] [Edit] [Select] [View] " "( ) foo (-)" " [ ] Section 1/1 [-]" " [ ] + zwj:�, zwnj:� " " " " " " " " " "###); Ok(()) }