From 2a5324b48c4692d86d30cd0310647c7e31eab075 Mon Sep 17 00:00:00 2001 From: Sreedev Kodichath Date: Sun, 26 Jul 2026 22:02:17 +0200 Subject: [PATCH] [ADD] tui: interactive terminal UI for resolving duplicates The tool could only act on duplicates non-interactively or through a line-oriented per-group prompt. Add a full-screen terminal UI (--tui) for browsing and resolving them visually. The UI is a two-pane master/detail view: a list of duplicate groups and, for the selected group, its files with per-file deletion marks. Files can be marked individually or in bulk by a keep-strategy (newest/oldest/first/ last/shortest/shallowest, reused from the resolver) applied to the current group or to all groups at once. A footer tracks how many files are marked and how much space would be reclaimed. Deletion is gated behind an explicit confirmation, a group always keeps at least one file, and the highlighted file can be opened in the system default application. The design keeps the interaction logic pure and independently testable: an App model translates an abstract key event into an outcome, so navigation, marking, strategy application, and post-deletion model updates are all unit tested without a terminal. Rendering (ratatui) and the destructive file I/O live in the event loop, which restores the terminal on both normal exit and panic so a crash never leaves it broken. --tui is mutually exclusive with --keep and --interactive. --- Cargo.lock | 1300 +++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 + src/main.rs | 12 +- src/params.rs | 3 + src/tui/app.rs | 499 +++++++++++++++++++ src/tui/mod.rs | 129 +++++ src/tui/ui.rs | 211 ++++++++ 7 files changed, 2123 insertions(+), 33 deletions(-) create mode 100644 src/tui/app.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/ui.rs diff --git a/Cargo.lock b/Cargo.lock index 68d5ecd..35b1eb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -82,6 +88,24 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -89,10 +113,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "bitflags" -version = "2.9.1" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] [[package]] name = "bstr" @@ -110,12 +170,33 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "bytesize" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.30" @@ -131,6 +212,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" version = "0.4.41" @@ -142,7 +229,7 @@ dependencies = [ "js-sys", "num-traits", "wasm-bindgen", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -191,6 +278,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "console" version = "0.16.0" @@ -204,12 +305,36 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -235,6 +360,53 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "csv" version = "1.3.1" @@ -256,6 +428,40 @@ dependencies = [ "memchr", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.104", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.104", +] + [[package]] name = "deduplicator" version = "0.3.2" @@ -269,14 +475,60 @@ dependencies = [ "gxhash", "indicatif", "memmap2", + "open", "pathdiff", "prettytable-rs", - "rand", + "rand 0.9.1", + "ratatui", "rayon", "tempfile", "unicode-segmentation", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.104", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -305,7 +557,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -319,6 +571,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "either" version = "1.15.0" @@ -331,6 +592,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.13" @@ -341,12 +608,82 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -366,10 +703,21 @@ checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.2+wasi-0.2.4", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "globset" version = "0.4.16" @@ -389,7 +737,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags", + "bitflags 2.13.1", "ignore", "walkdir", ] @@ -403,6 +751,28 @@ dependencies = [ "rustversion", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" @@ -415,6 +785,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "iana-time-zone" version = "0.1.63" @@ -439,6 +815,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "ignore" version = "0.4.23" @@ -469,6 +851,37 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + [[package]] name = "is-terminal" version = "0.4.16" @@ -480,12 +893,31 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -502,6 +934,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.19", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -510,9 +959,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" @@ -520,22 +975,65 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" dependencies = [ - "bitflags", + "bitflags 2.13.1", "libc", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "memchr" version = "2.7.5" @@ -551,6 +1049,79 @@ dependencies = [ "libc", ] +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -560,6 +1131,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -572,12 +1152,78 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "is-wsl", + "libc", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -585,10 +1231,110 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] -name = "portable-atomic" -version = "1.11.1" +name = "pest" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.7", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" @@ -637,6 +1383,21 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.1" @@ -644,7 +1405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -654,9 +1415,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.3" @@ -666,6 +1433,107 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.19", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.1", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width 0.2.1", +] + [[package]] name = "rayon" version = "1.10.0" @@ -686,6 +1554,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -708,6 +1585,18 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.9" @@ -725,13 +1614,22 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -760,23 +1658,56 @@ dependencies = [ ] [[package]] -name = "serde" -version = "1.0.219" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -785,12 +1716,93 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[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.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.104" @@ -837,6 +1849,82 @@ dependencies = [ "winapi", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -877,6 +1965,39 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -889,6 +2010,17 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.2.1", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -913,6 +2045,33 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1006,6 +2165,78 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.3", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1045,7 +2276,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -1078,13 +2309,19 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-result" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -1093,7 +2330,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -1114,6 +2351,15 @@ dependencies = [ "windows-targets 0.53.2", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1248,7 +2494,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index dc458b5..634aa47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,8 +26,10 @@ globwalk = "0.9.1" gxhash = { version = "3.4.1", default-features = false } indicatif = { version = "0.18.0", features = ["rayon"] } memmap2 = "0.9.7" +open = "5.4.0" pathdiff = "0.2.1" prettytable-rs = "0.10.0" +ratatui = "0.30.2" rayon = "1.6.1" unicode-segmentation = "1.12.0" diff --git a/src/main.rs b/src/main.rs index a6f59d7..974e032 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod pipeline; mod processor; mod resolver; mod scanner; +mod tui; use self::{formatter::Formatter, interactive::Interactive}; use anyhow::Result; @@ -17,12 +18,11 @@ fn main() -> Result<()> { let params = Params::parse(); let report = pipeline::run(¶ms)?; - match params.keep { - Some(strategy) => resolver::run(&report, strategy, params.force, ¶ms)?, - None => match params.interactive { - true => Interactive::init(&report.groups, ¶ms)?, - false => Formatter::print(&report, ¶ms), - }, + match (params.tui, params.keep, params.interactive) { + (true, _, _) => tui::run(report, ¶ms)?, + (false, Some(strategy), _) => resolver::run(&report, strategy, params.force, ¶ms)?, + (false, None, true) => Interactive::init(&report.groups, ¶ms)?, + (false, None, false) => Formatter::print(&report, ¶ms), } Ok(()) diff --git a/src/params.rs b/src/params.rs index cecdac2..6b6345c 100644 --- a/src/params.rs +++ b/src/params.rs @@ -48,6 +48,9 @@ pub struct Params { /// Use a specific cache file instead of the default location #[arg(long, value_name = "PATH")] pub cache_file: Option, + /// Browse and resolve duplicates in an interactive terminal UI + #[arg(long, conflicts_with_all = ["keep", "interactive"])] + pub tui: bool, } impl Params { diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..b9944ce --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,499 @@ +use std::path::PathBuf; + +use crate::fileinfo::FileInfo; +use crate::pipeline::DuplicateGroup; +use crate::resolver::{select_keeper, KeepStrategy}; + +pub const STRATEGIES: [KeepStrategy; 6] = [ + KeepStrategy::Newest, + KeepStrategy::Oldest, + KeepStrategy::First, + KeepStrategy::Last, + KeepStrategy::Shortest, + KeepStrategy::Shallowest, +]; + +pub fn strategy_label(strategy: KeepStrategy) -> &'static str { + match strategy { + KeepStrategy::Newest => "newest", + KeepStrategy::Oldest => "oldest", + KeepStrategy::First => "first", + KeepStrategy::Last => "last", + KeepStrategy::Shortest => "shortest", + KeepStrategy::Shallowest => "shallowest", + } +} + +#[derive(Clone, Copy, PartialEq)] +pub enum Focus { + Groups, + Files, +} + +#[derive(Clone, Copy, PartialEq)] +pub enum StrategyScope { + CurrentGroup, + AllGroups, +} + +#[derive(Clone, Copy, PartialEq)] +pub enum Popup { + None, + Strategy { scope: StrategyScope }, + ConfirmDelete, +} + +pub enum Key { + Up, + Down, + Tab, + Space, + Enter, + Esc, + Char(char), +} + +pub enum Outcome { + Continue, + Quit, + Delete, + Open(PathBuf), +} + +pub struct Group { + pub files: Vec, + pub marked: Vec, +} + +impl Group { + pub fn size(&self) -> u64 { + self.files.first().map(|f| f.size).unwrap_or(0) + } +} + +pub struct App { + pub groups: Vec, + pub group_cursor: usize, + pub file_cursor: usize, + pub focus: Focus, + pub popup: Popup, + pub strategy_cursor: usize, + pub status: Option, + pub should_quit: bool, +} + +fn clamp_add(cur: usize, delta: isize, max: usize) -> usize { + (cur as isize + delta).clamp(0, max as isize) as usize +} + +impl App { + pub fn from_groups(groups: Vec) -> Self { + let groups = groups + .into_iter() + .map(|g| Group { + marked: vec![false; g.files.len()], + files: g.files, + }) + .collect(); + + Self { + groups, + group_cursor: 0, + file_cursor: 0, + focus: Focus::Files, + popup: Popup::None, + strategy_cursor: 0, + status: None, + should_quit: false, + } + } + + pub fn move_group(&mut self, delta: isize) { + if self.groups.is_empty() { + return; + } + self.group_cursor = clamp_add(self.group_cursor, delta, self.groups.len() - 1); + let len = self.groups[self.group_cursor].files.len(); + self.file_cursor = self.file_cursor.min(len.saturating_sub(1)); + } + + pub fn move_file(&mut self, delta: isize) { + if let Some(g) = self.groups.get(self.group_cursor) { + self.file_cursor = clamp_add(self.file_cursor, delta, g.files.len().saturating_sub(1)); + } + } + + pub fn toggle_mark(&mut self) { + let fc = self.file_cursor; + if let Some(g) = self.groups.get_mut(self.group_cursor) { + if fc >= g.marked.len() { + return; + } + if !g.marked[fc] && g.marked.iter().filter(|m| !**m).count() <= 1 { + return; + } + g.marked[fc] = !g.marked[fc]; + } + } + + pub fn apply_strategy(&mut self, strategy: KeepStrategy, scope: StrategyScope) { + let indices: Vec = match scope { + StrategyScope::CurrentGroup => vec![self.group_cursor], + StrategyScope::AllGroups => (0..self.groups.len()).collect(), + }; + for gi in indices { + if let Some(g) = self.groups.get_mut(gi) { + if g.files.len() < 2 { + continue; + } + let keeper = select_keeper(&g.files, strategy); + for i in 0..g.marked.len() { + g.marked[i] = i != keeper; + } + } + } + } + + pub fn marked_count(&self) -> usize { + self.groups + .iter() + .flat_map(|g| g.marked.iter()) + .filter(|m| **m) + .count() + } + + pub fn reclaimable_bytes(&self) -> u64 { + self.groups + .iter() + .flat_map(|g| g.files.iter().zip(&g.marked)) + .filter(|(_, m)| **m) + .map(|(f, _)| f.size) + .sum() + } + + pub fn marked_paths(&self) -> Vec { + self.groups + .iter() + .flat_map(|g| g.files.iter().zip(&g.marked)) + .filter(|(_, m)| **m) + .map(|(f, _)| f.path.to_path_buf()) + .collect() + } + + pub fn apply_deletion(&mut self, deleted: &[PathBuf]) { + use std::collections::HashSet; + let removed: HashSet<&std::path::Path> = deleted.iter().map(|p| p.as_path()).collect(); + + for g in &mut self.groups { + let mut files = Vec::new(); + let mut marked = Vec::new(); + for (i, f) in g.files.iter().enumerate() { + if !removed.contains(&*f.path) { + files.push(f.clone()); + marked.push(g.marked[i]); + } + } + g.files = files; + g.marked = marked; + } + + self.groups.retain(|g| g.files.len() >= 2); + + if self.groups.is_empty() { + self.should_quit = true; + self.group_cursor = 0; + self.file_cursor = 0; + return; + } + + self.group_cursor = self.group_cursor.min(self.groups.len() - 1); + let len = self.groups[self.group_cursor].files.len(); + self.file_cursor = self.file_cursor.min(len.saturating_sub(1)); + } + + pub fn handle_key(&mut self, key: Key) -> Outcome { + match self.popup { + Popup::Strategy { scope } => self.handle_strategy_key(key, scope), + Popup::ConfirmDelete => self.handle_confirm_key(key), + Popup::None => self.handle_main_key(key), + } + } + + fn handle_main_key(&mut self, key: Key) -> Outcome { + self.status = None; + match key { + Key::Char('q') | Key::Esc => { + self.should_quit = true; + Outcome::Quit + } + Key::Tab => { + self.focus = match self.focus { + Focus::Groups => Focus::Files, + Focus::Files => Focus::Groups, + }; + Outcome::Continue + } + Key::Up | Key::Char('k') => { + self.move_in_focus(-1); + Outcome::Continue + } + Key::Down | Key::Char('j') => { + self.move_in_focus(1); + Outcome::Continue + } + Key::Space => { + if self.focus == Focus::Files { + self.toggle_mark(); + } + Outcome::Continue + } + Key::Char('s') => { + self.open_strategy(StrategyScope::CurrentGroup); + Outcome::Continue + } + Key::Char('S') => { + self.open_strategy(StrategyScope::AllGroups); + Outcome::Continue + } + Key::Char('o') => self.open_current(), + Key::Char('d') => { + if self.marked_count() > 0 { + self.popup = Popup::ConfirmDelete; + } else { + self.status = Some("nothing marked".to_string()); + } + Outcome::Continue + } + _ => Outcome::Continue, + } + } + + fn handle_strategy_key(&mut self, key: Key, scope: StrategyScope) -> Outcome { + match key { + Key::Up | Key::Char('k') => { + self.strategy_cursor = self.strategy_cursor.saturating_sub(1); + } + Key::Down | Key::Char('j') => { + self.strategy_cursor = (self.strategy_cursor + 1).min(STRATEGIES.len() - 1); + } + Key::Enter => { + let strategy = STRATEGIES[self.strategy_cursor]; + self.apply_strategy(strategy, scope); + self.popup = Popup::None; + } + Key::Esc => self.popup = Popup::None, + _ => {} + } + Outcome::Continue + } + + fn handle_confirm_key(&mut self, key: Key) -> Outcome { + match key { + Key::Char('y') => { + self.popup = Popup::None; + Outcome::Delete + } + Key::Char('n') | Key::Esc => { + self.popup = Popup::None; + Outcome::Continue + } + _ => Outcome::Continue, + } + } + + fn move_in_focus(&mut self, delta: isize) { + match self.focus { + Focus::Groups => self.move_group(delta), + Focus::Files => self.move_file(delta), + } + } + + fn open_strategy(&mut self, scope: StrategyScope) { + self.strategy_cursor = 0; + self.popup = Popup::Strategy { scope }; + } + + fn open_current(&self) -> Outcome { + match self.groups.get(self.group_cursor).and_then(|g| g.files.get(self.file_cursor)) { + Some(f) => Outcome::Open(f.path.to_path_buf()), + None => Outcome::Continue, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fileinfo::FileInfo; + use crate::pipeline::DuplicateGroup; + use std::path::PathBuf; + use std::time::{Duration, UNIX_EPOCH}; + + fn file(path: &str, size: u64, mtime_secs: u64) -> FileInfo { + FileInfo { + path: PathBuf::from(path).into_boxed_path(), + size, + modified: UNIX_EPOCH + Duration::from_secs(mtime_secs), + } + } + + fn group(files: Vec) -> DuplicateGroup { + DuplicateGroup { hash: 0, files } + } + + fn sample() -> App { + App::from_groups(vec![ + group(vec![file("/a/x", 10, 100), file("/a/y", 10, 200)]), + group(vec![file("/b/p", 5, 50), file("/b/q", 5, 60), file("/b/r", 5, 70)]), + ]) + } + + #[test] + fn move_group_clamps_at_bounds() { + let mut app = sample(); + app.move_group(-1); + assert_eq!(app.group_cursor, 0); + app.move_group(1); + assert_eq!(app.group_cursor, 1); + app.move_group(5); + assert_eq!(app.group_cursor, 1); + } + + #[test] + fn move_group_reclamps_file_cursor() { + let mut app = sample(); + app.group_cursor = 1; + app.file_cursor = 2; + app.move_group(-1); + assert_eq!(app.group_cursor, 0); + assert_eq!(app.file_cursor, 1); + } + + #[test] + fn move_file_clamps() { + let mut app = sample(); + app.move_file(-1); + assert_eq!(app.file_cursor, 0); + app.move_file(10); + assert_eq!(app.file_cursor, 1); + } + + #[test] + fn toggle_mark_blocks_marking_the_last_survivor() { + let mut app = sample(); + app.group_cursor = 0; + app.file_cursor = 0; + app.toggle_mark(); + assert!(app.groups[0].marked[0]); + app.file_cursor = 1; + app.toggle_mark(); + assert!(!app.groups[0].marked[1]); + } + + #[test] + fn toggle_mark_allows_unmark() { + let mut app = sample(); + app.file_cursor = 0; + app.toggle_mark(); + app.toggle_mark(); + assert!(!app.groups[0].marked[0]); + } + + #[test] + fn apply_strategy_current_group_marks_all_but_keeper() { + let mut app = sample(); + app.group_cursor = 0; + app.apply_strategy(KeepStrategy::Newest, StrategyScope::CurrentGroup); + assert_eq!(app.groups[0].marked, vec![true, false]); + assert_eq!(app.groups[1].marked, vec![false, false, false]); + } + + #[test] + fn apply_strategy_all_groups() { + let mut app = sample(); + app.apply_strategy(KeepStrategy::Oldest, StrategyScope::AllGroups); + assert_eq!(app.groups[0].marked, vec![false, true]); + assert_eq!(app.groups[1].marked, vec![false, true, true]); + } + + #[test] + fn totals_sum_marked_only() { + let mut app = sample(); + app.apply_strategy(KeepStrategy::Newest, StrategyScope::AllGroups); + assert_eq!(app.marked_count(), 3); + assert_eq!(app.reclaimable_bytes(), 20); + } + + #[test] + fn marked_paths_lists_marked_files() { + let mut app = sample(); + app.group_cursor = 0; + app.file_cursor = 0; + app.toggle_mark(); + assert_eq!(app.marked_paths(), vec![PathBuf::from("/a/x")]); + } + + #[test] + fn apply_deletion_removes_files_and_drops_small_groups() { + let mut app = sample(); + app.apply_deletion(&[PathBuf::from("/a/x")]); + assert_eq!(app.groups.len(), 1); + assert_eq!(app.groups[0].files.len(), 3); + } + + #[test] + fn apply_deletion_emptying_everything_sets_quit() { + let mut app = App::from_groups(vec![group(vec![file("/a/x", 1, 1), file("/a/y", 1, 2)])]); + app.apply_deletion(&[PathBuf::from("/a/x")]); + assert!(app.groups.is_empty()); + assert!(app.should_quit); + } + + #[test] + fn handle_key_quits_on_q() { + let mut app = sample(); + assert!(matches!(app.handle_key(Key::Char('q')), Outcome::Quit)); + assert!(app.should_quit); + } + + #[test] + fn handle_key_space_marks_in_files_focus() { + let mut app = sample(); + app.focus = Focus::Files; + app.handle_key(Key::Space); + assert!(app.groups[0].marked[0]); + } + + #[test] + fn handle_key_strategy_popup_apply_flow() { + let mut app = sample(); + app.handle_key(Key::Char('S')); + assert!(matches!(app.popup, Popup::Strategy { .. })); + app.handle_key(Key::Enter); + assert!(matches!(app.popup, Popup::None)); + assert_eq!(app.groups[0].marked, vec![true, false]); + } + + #[test] + fn handle_key_delete_requires_marks_then_confirms() { + let mut app = sample(); + app.handle_key(Key::Char('d')); + assert!(matches!(app.popup, Popup::None)); + app.focus = Focus::Files; + app.handle_key(Key::Space); + app.handle_key(Key::Char('d')); + assert!(matches!(app.popup, Popup::ConfirmDelete)); + assert!(matches!(app.handle_key(Key::Char('y')), Outcome::Delete)); + assert!(matches!(app.popup, Popup::None)); + } + + #[test] + fn handle_key_open_returns_path() { + let mut app = sample(); + app.focus = Focus::Files; + match app.handle_key(Key::Char('o')) { + Outcome::Open(p) => assert_eq!(p, PathBuf::from("/a/x")), + _ => panic!("expected Open"), + } + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..698ae89 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,129 @@ +pub mod app; +mod ui; + +use std::io::{self, Stdout}; + +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use ratatui::crossterm::execute; +use ratatui::crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::Terminal; + +use crate::params::Params; +use crate::pipeline::DedupReport; +use app::{App, Key, Outcome}; + +type Tui = Terminal>; + +pub fn run(report: DedupReport, params: &Params) -> Result<()> { + if report.groups.is_empty() { + println!("No duplicates found matching your search criteria."); + return Ok(()); + } + + let mut app = App::from_groups(report.groups); + let mut terminal = setup_terminal()?; + let result = event_loop(&mut terminal, &mut app, params); + restore_terminal(&mut terminal)?; + result +} + +fn setup_terminal() -> Result { + install_panic_hook(); + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + Ok(Terminal::new(CrosstermBackend::new(stdout))?) +} + +fn restore_terminal(terminal: &mut Tui) -> Result<()> { + let _ = disable_raw_mode(); + let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen); + let _ = terminal.show_cursor(); + Ok(()) +} + +fn install_panic_hook() { + let original = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + original(info); + })); +} + +fn event_loop(terminal: &mut Tui, app: &mut App, params: &Params) -> Result<()> { + loop { + terminal.draw(|frame| ui::draw(frame, app, params))?; + if app.should_quit { + break; + } + + if let Event::Key(key) = event::read()? { + if key.kind != KeyEventKind::Press { + continue; + } + if let Some(translated) = translate(key) { + match app.handle_key(translated) { + Outcome::Quit => {} + Outcome::Continue => {} + Outcome::Delete => perform_deletion(app), + Outcome::Open(path) => { + if let Err(err) = open::that(&path) { + app.status = Some(format!("open failed: {err}")); + } + } + } + } + } + + if app.should_quit { + break; + } + } + Ok(()) +} + +fn translate(key: KeyEvent) -> Option { + Some(match key.code { + KeyCode::Up => Key::Up, + KeyCode::Down => Key::Down, + KeyCode::Tab => Key::Tab, + KeyCode::Enter => Key::Enter, + KeyCode::Esc => Key::Esc, + KeyCode::Char(' ') => Key::Space, + KeyCode::Char(c) => Key::Char(c), + _ => return None, + }) +} + +fn perform_deletion(app: &mut App) { + let paths = app.marked_paths(); + let mut deleted = Vec::new(); + let mut freed: u64 = 0; + let mut failures: u64 = 0; + + for path in &paths { + let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + match std::fs::remove_file(path) { + Ok(_) => { + deleted.push(path.clone()); + freed += size; + } + Err(_) => failures += 1, + } + } + + app.apply_deletion(&deleted); + app.status = Some(match failures { + 0 => format!("deleted {}, freed {}", deleted.len(), bytesize::ByteSize::b(freed)), + _ => format!( + "deleted {}, {failures} failed, freed {}", + deleted.len(), + bytesize::ByteSize::b(freed) + ), + }); +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..be312eb --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,211 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; +use ratatui::Frame; + +use crate::params::Params; +use crate::formatter::Formatter; + +use super::app::{strategy_label, App, Focus, Popup, StrategyScope, STRATEGIES}; + +pub fn draw(frame: &mut Frame, app: &App, params: &Params) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(frame.area()); + + let panes = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) + .split(rows[0]); + + draw_groups(frame, app, panes[0]); + draw_files(frame, app, params, panes[1]); + draw_footer(frame, app, rows[1]); + + match app.popup { + Popup::Strategy { scope } => draw_strategy_popup(frame, app, scope), + Popup::ConfirmDelete => draw_confirm_popup(frame, app), + Popup::None => {} + } +} + +fn pane_block(title: &str, focused: bool) -> Block<'_> { + let block = Block::default().borders(Borders::ALL).title(title.to_string()); + match focused { + true => block.border_style(Style::new().yellow()), + false => block, + } +} + +fn draw_groups(frame: &mut Frame, app: &App, area: Rect) { + let items: Vec = app + .groups + .iter() + .enumerate() + .map(|(i, g)| { + ListItem::new(format!( + "{:>3} {} files {}", + i + 1, + g.files.len(), + bytesize::ByteSize::b(g.size()) + )) + }) + .collect(); + + let list = List::new(items) + .block(pane_block("Groups", app.focus == Focus::Groups)) + .highlight_style(Style::new().reversed()); + + let mut state = ListState::default(); + state.select(Some(app.group_cursor)); + frame.render_stateful_widget(list, area, &mut state); +} + +fn draw_files(frame: &mut Frame, app: &App, params: &Params, area: Rect) { + let title = format!( + "Group {}/{}", + (app.group_cursor + 1).min(app.groups.len().max(1)), + app.groups.len() + ); + + let items: Vec = match app.groups.get(app.group_cursor) { + Some(g) => g + .files + .iter() + .enumerate() + .map(|(i, f)| { + let box_char = if g.marked[i] { "[x]" } else { "[ ]" }; + let path = Formatter::human_path(f, params, 0).unwrap_or_default(); + let size = Formatter::human_filesize(f).unwrap_or_default(); + let mtime = Formatter::human_mtime(f).unwrap_or_default(); + let line = format!("{box_char} {} {} {}", path.trim_end(), size.trim_start(), mtime); + match g.marked[i] { + true => ListItem::new(Line::from(line).style(Style::new().red())), + false => ListItem::new(line), + } + }) + .collect(), + None => Vec::new(), + }; + + let list = List::new(items) + .block(pane_block(&title, app.focus == Focus::Files)) + .highlight_style(Style::new().reversed()); + + let mut state = ListState::default(); + state.select(Some(app.file_cursor)); + frame.render_stateful_widget(list, area, &mut state); +} + +fn draw_footer(frame: &mut Frame, app: &App, area: Rect) { + let hints = "[Tab]pane [Space]mark [s]group [S]all [o]pen [d]elete [q]uit"; + let left = match &app.status { + Some(s) => s.clone(), + None => format!( + "marked {} ยท reclaim {}", + app.marked_count(), + bytesize::ByteSize::b(app.reclaimable_bytes()) + ), + }; + let text = format!("{left} {hints}"); + frame.render_widget(Paragraph::new(text), area); +} + +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height.saturating_sub(height) / 2; + Rect { + x, + y, + width: width.min(area.width), + height: height.min(area.height), + } +} + +fn draw_strategy_popup(frame: &mut Frame, app: &App, scope: StrategyScope) { + let title = match scope { + StrategyScope::CurrentGroup => "Keep in this group", + StrategyScope::AllGroups => "Keep in ALL groups", + }; + let items: Vec = STRATEGIES + .iter() + .map(|s| ListItem::new(strategy_label(*s))) + .collect(); + + let area = centered_rect(28, (STRATEGIES.len() as u16) + 2, frame.area()); + frame.render_widget(Clear, area); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(title)) + .highlight_style(Style::new().reversed()); + + let mut state = ListState::default(); + state.select(Some(app.strategy_cursor)); + frame.render_stateful_widget(list, area, &mut state); +} + +fn draw_confirm_popup(frame: &mut Frame, app: &App) { + let area = centered_rect(48, 3, frame.area()); + frame.render_widget(Clear, area); + let text = format!( + "Delete {} files, reclaim {}? [y/N]", + app.marked_count(), + bytesize::ByteSize::b(app.reclaimable_bytes()) + ); + frame.render_widget( + Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Confirm")), + area, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fileinfo::FileInfo; + use crate::pipeline::DuplicateGroup; + use crate::tui::app::App; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + use std::path::PathBuf; + use std::time::{Duration, UNIX_EPOCH}; + + fn sample_app() -> App { + let files = vec![ + FileInfo { + path: PathBuf::from("/tmp/report.pdf").into_boxed_path(), + size: 4_000_000, + modified: UNIX_EPOCH + Duration::from_secs(1_700_000_000), + }, + FileInfo { + path: PathBuf::from("/tmp/report-copy.pdf").into_boxed_path(), + size: 4_000_000, + modified: UNIX_EPOCH + Duration::from_secs(1_700_100_000), + }, + ]; + App::from_groups(vec![DuplicateGroup { hash: 0, files }]) + } + + #[test] + fn draw_renders_without_panic_and_shows_content() { + let app = sample_app(); + let params = crate::params::Params::default(); + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|f| draw(f, &app, ¶ms)).unwrap(); + + let text: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + + assert!(text.contains("Groups")); + assert!(text.contains("report")); + assert!(text.contains("marked")); + } +}