commit 16a2155d680d40f0b633bb75a384a1f18bc6e045 Author: SW Date: Sun Aug 16 19:30:55 2026 +0200 Initial resticview implementation diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6eee8f7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Tag to release (e.g. v0.1.6)' + required: true + +permissions: + contents: write + +jobs: + build-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo build --release + - run: cargo test --release + - run: cargo clippy --release -- -D warnings + - uses: actions/upload-artifact@v4 + with: + name: resticview-macos + path: target/release/resticview + + build-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo install cargo-deb + - run: cargo build --release + - run: cargo test --release + - run: cargo clippy --release -- -D warnings + - run: cargo deb + - uses: actions/upload-artifact@v4 + with: + name: resticview-linux + path: | + target/release/resticview + target/debian/*.deb + + release: + needs: [build-macos, build-linux] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: resticview-macos + path: artifacts/macos + - uses: actions/download-artifact@v4 + with: + name: resticview-linux + path: artifacts/linux + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag || github.ref_name }} + files: | + artifacts/macos/resticview + artifacts/linux/release/resticview + artifacts/linux/debian/*.deb + generate_release_notes: true + overwrite_files: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9ce6ffa --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1882 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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 = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", +] + +[[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.119", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[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 = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[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 = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "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.20", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +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.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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", + "windows-sys", +] + +[[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.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[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", +] + +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.20", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[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", +] + +[[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.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "resticview" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "crossterm", + "dirs", + "ratatui", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[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.119", +] + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[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", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e6aef4d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "resticview" +version = "0.1.0" +edition = "2024" +authors = ["janvete"] +description = "TUI browser for restic backup repositories" +license = "MIT" +repository = "https://github.com/janvete/resticview" +homepage = "https://github.com/janvete/resticview" +keywords = ["cli", "tui", "restic", "backup"] +categories = ["command-line-utilities"] + +[dependencies] +anyhow = "1" +clap = { version = "4.6.1", features = ["derive"] } +crossterm = "0.29.0" +dirs = "6.0.0" +ratatui = "0.30.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +tempfile = "3" + +[package.metadata.deb] +maintainer = "janvete " +copyright = "2026, janvete" +extended-description = "TUI browser for restic backup repositories." +section = "utils" +priority = "optional" +assets = [ + ["target/release/resticview", "usr/bin/resticview", "755"], + ["README.md", "usr/share/doc/resticview/README.md", "644"], +] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8d71d4 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# resticview + +A TUI browser for restic backup repositories. Browse snapshots, explore the file tree of any snapshot, view repository stats, and restore whole snapshots or single paths on demand. + +## Installation + +### macOS (Homebrew) + +```bash +brew tap janvete/tools +brew install resticview +``` + +### From source + +```bash +cargo install --git https://github.com/janvete/resticview +``` + +`restic` must be installed and on `PATH`. + +## Usage + +resticview reads restic credentials (`RESTIC_REPOSITORY`, `RESTIC_PASSWORD`, …) from env files stored in `~/.restic`. It picks up every hidden file (name starting with `.`, e.g. `.env`, `.hetzner`) directly in that directory as well as inside hidden subdirectories, one level deep (e.g. `~/.restic/.work/.env`). If several env files are found, a picker is shown; with exactly one it is loaded directly. + +```bash +resticview # use env files from ~/.restic +resticview --env-dir DIR # use a different env directory +resticview --select # always show the env file picker +``` + +The main view has three panels: snapshots (left), the file tree of the selected snapshot (middle), and repository stats (right). The focused panel has a highlighted border. + +Restore (`r`) asks for a target directory (pre-filled with `~/restic-restore-`), asks for confirmation, and then runs `restic restore` in the background. With the focus on the snapshot list the whole snapshot is restored; with the focus on the file tree only the selected path is restored (`--include`). + +## Controls + +| Key | Action | +|-----|--------| +| `q` / `Ctrl-C` | quit | +| `Tab` / `Shift-Tab` | cycle focus between panels | +| `j` / `k` or `↓` / `↑` | move selection / scroll | +| mouse wheel | scroll the focused panel | +| `Enter` | expand/collapse directory (Files panel) | +| `h` / `l` or `←` / `→` | collapse / expand directory | +| `g` / `G` | jump to top / bottom | +| `r` | restore selected snapshot or path | +| `R` | refresh snapshots and repository stats | +| `Esc` | close prompt / dialog, clear status | + +## License + +MIT diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..9768e73 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,668 @@ +//! Application state machine and background-thread plumbing. +//! +//! All restic calls run on plain `std::thread`s and report back through a +//! single `mpsc` channel (`BgMsg`). The tree loader uses a generation counter +//! plus a cancel flag: when the user switches snapshots while a load is in +//! flight, the stale thread is signalled to kill its restic child and its +//! result is discarded on arrival. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, mpsc}; +use std::thread; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; +use ratatui::widgets::ListState; + +use crate::envfile::EnvFile; +use crate::restic::{self, LsLine, Snapshot, Stats}; +use crate::tree::Tree; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + Snapshots, + Tree, + Stats, +} + +impl Focus { + pub fn next(self) -> Self { + match self { + Focus::Snapshots => Focus::Tree, + Focus::Tree => Focus::Stats, + Focus::Stats => Focus::Snapshots, + } + } + + pub fn prev(self) -> Self { + match self { + Focus::Snapshots => Focus::Stats, + Focus::Tree => Focus::Snapshots, + Focus::Stats => Focus::Tree, + } + } +} + +/// State of a background-loaded value. +pub enum LoadState { + Loading, + Ready(T), + Failed(String), +} + +/// State of the file-tree panel (the tree itself lives in `App::tree`). +#[derive(Debug, Default, PartialEq, Eq)] +pub enum TreeStatus { + #[default] + Idle, + Loading, + Failed(String), +} + +/// Restore flow: target input -> confirmation -> background run. +pub enum RestoreState { + Idle, + Input { + input: String, + cursor: usize, + snapshot: String, + include: Option, + }, + Confirm { + target: String, + snapshot: String, + include: Option, + }, + Running, +} + +/// Messages sent from background threads to the UI loop. +pub enum BgMsg { + Snapshots(Result, String>), + Tree { + generation: u64, + snapshot: String, + result: Result, String>, + }, + Stats(Result), + Restore(Result), +} + +const SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +pub struct App { + env: Arc>, + /// Display name of the selected env file (relative path). + pub env_rel_path: String, + /// RESTIC_REPOSITORY value, for display only. + pub repo: String, + + pub focus: Focus, + pub should_quit: bool, + pub tick: u64, + + pub snapshots: LoadState>, + pub snap_state: ListState, + + /// Currently displayed tree together with its snapshot short_id. + pub tree: Option<(String, Tree)>, + pub tree_status: TreeStatus, + tree_cache: HashMap, + tree_generation: u64, + tree_cancel: Arc, + + pub stats: LoadState, + + pub restore_state: RestoreState, + /// Last finished restore: (success, message). + pub restore_outcome: Option<(bool, String)>, + restore_cancel: Arc, + + /// Transient status-line message. + pub status: Option, + + tx: Sender, + rx: Receiver, +} + +impl App { + pub fn new(env_file: EnvFile) -> App { + let (tx, rx) = mpsc::channel(); + let repo = env_file.repository().to_string(); + let mut app = App { + env: Arc::new(env_file.vars), + env_rel_path: env_file.rel_path, + repo, + focus: Focus::Snapshots, + should_quit: false, + tick: 0, + snapshots: LoadState::Loading, + snap_state: ListState::default(), + tree: None, + tree_status: TreeStatus::Idle, + tree_cache: HashMap::new(), + tree_generation: 0, + tree_cancel: Arc::new(AtomicBool::new(false)), + stats: LoadState::Loading, + restore_state: RestoreState::Idle, + restore_outcome: None, + restore_cancel: Arc::new(AtomicBool::new(false)), + status: None, + tx, + rx, + }; + app.spawn_snapshots(); + app.spawn_stats(); + app + } + + pub fn spinner(&self) -> char { + SPINNER[(self.tick as usize) % SPINNER.len()] + } + + pub fn selected_snapshot(&self) -> Option<&Snapshot> { + let LoadState::Ready(snaps) = &self.snapshots else { + return None; + }; + self.snap_state.selected().and_then(|i| snaps.get(i)) + } + + // ---- background loads ------------------------------------------------- + + fn spawn_snapshots(&mut self) { + self.snapshots = LoadState::Loading; + let env = self.env.clone(); + let tx = self.tx.clone(); + thread::spawn(move || { + let result = restic::snapshots(&env).map_err(|e| format!("{e:#}")); + let _ = tx.send(BgMsg::Snapshots(result)); + }); + } + + fn spawn_stats(&mut self) { + self.stats = LoadState::Loading; + let env = self.env.clone(); + let tx = self.tx.clone(); + thread::spawn(move || { + let result = restic::stats(&env).map_err(|e| format!("{e:#}")); + let _ = tx.send(BgMsg::Stats(result)); + }); + } + + /// Make sure the tree for the currently selected snapshot is loaded: + /// serve it from the cache when possible, otherwise start a background + /// load (cancelling any stale one). + fn ensure_tree(&mut self) { + let Some(snap) = self.selected_snapshot() else { + self.tree = None; + self.tree_status = TreeStatus::Idle; + return; + }; + let sid = snap.short_id.clone(); + if matches!(&self.tree, Some((cur, _)) if *cur == sid) { + return; + } + // Stash the current tree back into the cache. + if let Some((cur, tree)) = self.tree.take() { + self.tree_cache.insert(cur, tree); + } + if let Some(tree) = self.tree_cache.remove(&sid) { + self.tree = Some((sid, tree)); + self.tree_status = TreeStatus::Idle; + return; + } + self.tree_generation += 1; + self.tree_cancel.store(true, Ordering::Relaxed); + let cancel = Arc::new(AtomicBool::new(false)); + self.tree_cancel = cancel.clone(); + self.tree_status = TreeStatus::Loading; + let generation = self.tree_generation; + let env = self.env.clone(); + let tx = self.tx.clone(); + thread::spawn(move || { + let result = restic::ls(&env, &sid, &cancel).map_err(|e| format!("{e:#}")); + let _ = tx.send(BgMsg::Tree { + generation, + snapshot: sid, + result, + }); + }); + } + + /// Refresh snapshots and stats (`R`); drops the tree cache. + pub fn refresh(&mut self) { + self.tree_cancel.store(true, Ordering::Relaxed); + self.tree_generation += 1; + self.tree_cache.clear(); + self.tree = None; + self.tree_status = TreeStatus::Idle; + self.spawn_snapshots(); + self.spawn_stats(); + self.status = Some("refreshing…".to_string()); + } + + /// Signal all background children (restic ls / restore) to stop. Called on quit. + pub fn cancel_background(&self) { + self.tree_cancel.store(true, Ordering::Relaxed); + self.restore_cancel.store(true, Ordering::Relaxed); + } + + pub fn drain_messages(&mut self) { + while let Ok(msg) = self.rx.try_recv() { + self.handle_msg(msg); + } + } + + fn handle_msg(&mut self, msg: BgMsg) { + match msg { + BgMsg::Snapshots(Ok(mut snaps)) => { + snaps.sort_by(|a, b| b.time.cmp(&a.time)); + let n = snaps.len(); + self.snapshots = LoadState::Ready(snaps); + self.snap_state.select(if n > 0 { Some(0) } else { None }); + self.status = Some(format!("loaded {n} snapshot(s)")); + self.ensure_tree(); + } + BgMsg::Snapshots(Err(e)) => { + self.snapshots = LoadState::Failed(e); + } + BgMsg::Tree { + generation, + snapshot, + result, + } => { + let current = self.selected_snapshot().map(|s| s.short_id.clone()); + if generation != self.tree_generation || current.as_deref() != Some(snapshot.as_str()) { + return; // stale result: discard + } + match result { + Ok(lines) => { + self.tree = Some((snapshot, Tree::build(&lines))); + self.tree_status = TreeStatus::Idle; + } + Err(e) => { + self.tree_status = TreeStatus::Failed(e); + } + } + } + BgMsg::Stats(Ok(stats)) => { + self.stats = LoadState::Ready(stats); + } + BgMsg::Stats(Err(e)) => { + self.stats = LoadState::Failed(e); + } + BgMsg::Restore(result) => { + self.restore_state = RestoreState::Idle; + match result { + Ok(msg) => self.restore_outcome = Some((true, msg)), + Err(e) => self.restore_outcome = Some((false, truncate(&e, 300))), + } + } + } + } + + // ---- input handling --------------------------------------------------- + + pub fn on_key(&mut self, key: KeyEvent) { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + self.should_quit = true; + return; + } + match self.restore_state { + RestoreState::Input { .. } => { + self.on_input_key(key); + return; + } + RestoreState::Confirm { .. } => { + self.on_confirm_key(key); + return; + } + _ => {} + } + match key.code { + KeyCode::Char('q') => self.should_quit = true, + KeyCode::Tab => self.focus = self.focus.next(), + KeyCode::BackTab => self.focus = self.focus.prev(), + KeyCode::Esc => { + self.status = None; + self.restore_outcome = None; + } + KeyCode::Char('R') => self.refresh(), + KeyCode::Char('r') => self.start_restore_prompt(), + KeyCode::Char('g') => self.go_first(), + KeyCode::Char('G') => self.go_last(), + KeyCode::Char('j') | KeyCode::Down => self.move_selection(1), + KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1), + KeyCode::Char('h') | KeyCode::Left => { + if let Some((_, tree)) = &mut self.tree + && self.focus == Focus::Tree + { + tree.collapse_selected(); + } + } + KeyCode::Char('l') | KeyCode::Right => { + if let Some((_, tree)) = &mut self.tree + && self.focus == Focus::Tree + { + tree.expand_selected(); + } + } + KeyCode::Enter => match self.focus { + Focus::Tree => { + if let Some((_, tree)) = &mut self.tree { + tree.toggle_selected(); + } + } + Focus::Snapshots => self.focus = Focus::Tree, + Focus::Stats => {} + }, + _ => {} + } + } + + pub fn on_mouse(&mut self, kind: MouseEventKind) { + let delta = match kind { + MouseEventKind::ScrollUp => -1, + MouseEventKind::ScrollDown => 1, + _ => return, + }; + self.move_selection(delta); + } + + fn move_selection(&mut self, delta: i64) { + match self.focus { + Focus::Snapshots => { + let LoadState::Ready(snaps) = &self.snapshots else { + return; + }; + if snaps.is_empty() { + return; + } + let cur = self.snap_state.selected().unwrap_or(0) as i64; + let next = (cur + delta).clamp(0, snaps.len() as i64 - 1) as usize; + if Some(next) != self.snap_state.selected() { + self.snap_state.select(Some(next)); + self.ensure_tree(); + } + } + Focus::Tree => { + if let Some((_, tree)) = &mut self.tree { + tree.move_by(delta); + } + } + Focus::Stats => {} + } + } + + fn go_first(&mut self) { + match self.focus { + Focus::Snapshots => { + if self.snap_state.selected() != Some(0) { + self.snap_state.select(Some(0)); + self.ensure_tree(); + } + } + Focus::Tree => { + if let Some((_, tree)) = &mut self.tree { + tree.first(); + } + } + Focus::Stats => {} + } + } + + fn go_last(&mut self) { + match self.focus { + Focus::Snapshots => { + if let LoadState::Ready(snaps) = &self.snapshots + && !snaps.is_empty() + { + let last = snaps.len() - 1; + if self.snap_state.selected() != Some(last) { + self.snap_state.select(Some(last)); + self.ensure_tree(); + } + } + } + Focus::Tree => { + if let Some((_, tree)) = &mut self.tree { + tree.last(); + } + } + Focus::Stats => {} + } + } + + // ---- restore flow ----------------------------------------------------- + + fn start_restore_prompt(&mut self) { + if matches!(self.restore_state, RestoreState::Running) { + self.status = Some("a restore is already running".to_string()); + return; + } + let Some(snap) = self.selected_snapshot() else { + self.status = Some("no snapshot selected".to_string()); + return; + }; + let sid = snap.short_id.clone(); + let include = match self.focus { + Focus::Snapshots => None, + Focus::Tree => match &self.tree { + Some((_, tree)) => match tree.selected_node() { + Some(node) => Some(node.path.clone()), + None => { + self.status = Some("no file selected".to_string()); + return; + } + }, + None => { + self.status = Some("files not loaded yet".to_string()); + return; + } + }, + Focus::Stats => { + self.status = Some("focus Snapshots or Files to restore".to_string()); + return; + } + }; + let input = format!("~/restic-restore-{sid}"); + let cursor = input.len(); + self.restore_state = RestoreState::Input { + input, + cursor, + snapshot: sid, + include, + }; + } + + fn on_input_key(&mut self, key: KeyEvent) { + enum Action { + None, + Cancel, + Confirm, + EmptyTarget, + } + let action = { + let RestoreState::Input { + input, cursor, .. + } = &mut self.restore_state + else { + return; + }; + match key.code { + KeyCode::Esc => Action::Cancel, + KeyCode::Enter => { + if input.trim().is_empty() { + Action::EmptyTarget + } else { + Action::Confirm + } + } + KeyCode::Backspace => { + if *cursor > 0 { + let prev = prev_char_boundary(input, *cursor); + input.drain(prev..*cursor); + *cursor = prev; + } + Action::None + } + KeyCode::Delete => { + if *cursor < input.len() { + let next = next_char_boundary(input, *cursor); + input.drain(*cursor..next); + } + Action::None + } + KeyCode::Left => { + *cursor = prev_char_boundary(input, *cursor); + Action::None + } + KeyCode::Right => { + *cursor = next_char_boundary(input, *cursor); + Action::None + } + KeyCode::Home => { + *cursor = 0; + Action::None + } + KeyCode::End => { + *cursor = input.len(); + Action::None + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + input.clear(); + *cursor = 0; + Action::None + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + input.insert(*cursor, c); + *cursor += c.len_utf8(); + Action::None + } + _ => Action::None, + } + }; + match action { + Action::None => {} + Action::Cancel => self.restore_state = RestoreState::Idle, + Action::EmptyTarget => { + self.status = Some("target directory must not be empty".to_string()); + } + Action::Confirm => { + let old = std::mem::replace(&mut self.restore_state, RestoreState::Idle); + if let RestoreState::Input { + input, + snapshot, + include, + .. + } = old + { + self.restore_state = RestoreState::Confirm { + target: input.trim().to_string(), + snapshot, + include, + }; + } + } + } + } + + fn on_confirm_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + let old = std::mem::replace(&mut self.restore_state, RestoreState::Idle); + if let RestoreState::Confirm { + target, + snapshot, + include, + } = old + { + self.spawn_restore(snapshot, target, include); + } + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + self.restore_state = RestoreState::Idle; + } + _ => {} + } + } + + fn spawn_restore(&mut self, snapshot: String, target: String, include: Option) { + let path = restic::expand_tilde(&target); + let env = self.env.clone(); + let cancel = self.restore_cancel.clone(); + cancel.store(false, Ordering::Relaxed); + let tx = self.tx.clone(); + thread::spawn(move || { + let result = restic::restore(&env, &snapshot, &path, include.as_deref(), &cancel) + .map(|out| { + out.lines() + .find(|l| l.starts_with("Summary:")) + .map(str::to_string) + .unwrap_or_else(|| format!("restore finished: {}", path.display())) + }) + .map_err(|e| format!("{e:#}")); + let _ = tx.send(BgMsg::Restore(result)); + }); + self.restore_state = RestoreState::Running; + self.restore_outcome = None; + } +} + +fn prev_char_boundary(s: &str, cursor: usize) -> usize { + s[..cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0) +} + +fn next_char_boundary(s: &str, cursor: usize) -> usize { + s[cursor..] + .char_indices() + .nth(1) + .map(|(i, _)| cursor + i) + .unwrap_or(s.len()) +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn char_boundaries() { + let s = "aáb"; + assert_eq!(prev_char_boundary(s, 3), 1); // before 'á' (2 bytes) + assert_eq!(next_char_boundary(s, 1), 3); + assert_eq!(next_char_boundary(s, 3), 4); + assert_eq!(prev_char_boundary(s, 0), 0); + } + + #[test] + fn truncate_respects_boundaries() { + assert_eq!(truncate("hello", 10), "hello"); + assert_eq!(truncate("hello world", 5), "hello…"); + assert_eq!(truncate("aáb", 2), "a…"); + } + + #[test] + fn focus_cycles() { + assert_eq!(Focus::Snapshots.next(), Focus::Tree); + assert_eq!(Focus::Tree.next(), Focus::Stats); + assert_eq!(Focus::Stats.next(), Focus::Snapshots); + assert_eq!(Focus::Snapshots.prev(), Focus::Stats); + } +} diff --git a/src/envfile.rs b/src/envfile.rs new file mode 100644 index 0000000..183e2cf --- /dev/null +++ b/src/envfile.rs @@ -0,0 +1,228 @@ +//! Scanning and parsing of restic credential env files. +//! +//! Env files are plain `KEY=VALUE` files (optionally with an `export ` prefix, +//! quotes and `#` comments). They are parsed literally — no shell evaluation. + +use std::collections::HashMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// A single env file discovered in the env directory, with parsed variables. +#[derive(Debug, Clone)] +pub struct EnvFile { + /// Path relative to the env dir, e.g. `.env` or `.work/.env`. Used for display. + pub rel_path: String, + /// Parsed KEY=VALUE pairs. + pub vars: HashMap, +} + +impl EnvFile { + pub fn get(&self, key: &str) -> Option<&str> { + self.vars.get(key).map(String::as_str) + } + + /// Value of RESTIC_REPOSITORY, for display in the picker and stats panel. + pub fn repository(&self) -> &str { + self.get("RESTIC_REPOSITORY") + .unwrap_or("(no RESTIC_REPOSITORY)") + } +} + +/// Parse env file content: `KEY=VALUE` lines with an optional `export ` prefix, +/// single/double quotes, `#` comments and empty lines. No shell evaluation. +pub fn parse(content: &str) -> HashMap { + let mut vars = HashMap::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line + .strip_prefix("export ") + .map(str::trim_start) + .unwrap_or(line); + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + if key.is_empty() { + continue; + } + vars.insert(key.to_string(), parse_value(value.trim())); + } + vars +} + +/// Strip surrounding quotes; for unquoted values strip a trailing ` # comment`. +fn parse_value(raw: &str) -> String { + let bytes = raw.as_bytes(); + if raw.len() >= 2 { + let (first, last) = (bytes[0], bytes[bytes.len() - 1]); + if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') { + return raw[1..raw.len() - 1].to_string(); + } + } + match raw.find(" #") { + Some(i) => raw[..i].trim_end().to_string(), + None => raw.to_string(), + } +} + +fn is_hidden(name: &str) -> bool { + name.starts_with('.') +} + +/// Scan `dir` for env files: every hidden file (`.*`) directly in `dir`, plus +/// hidden files one level deep inside hidden subdirectories (`.*/`). Results +/// are sorted by relative path. +pub fn scan(dir: &Path) -> io::Result> { + let mut paths: Vec = Vec::new(); + for entry in fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + let file_type = entry.file_type()?; + if file_type.is_file() && is_hidden(name) { + paths.push(entry.path()); + } else if file_type.is_dir() && is_hidden(name) { + let Ok(sub_entries) = fs::read_dir(entry.path()) else { + continue; + }; + for sub in sub_entries { + let Ok(sub) = sub else { continue }; + let sub_name = sub.file_name(); + let Some(sub_name) = sub_name.to_str() else { continue }; + if let Ok(ft) = sub.file_type() + && ft.is_file() + && is_hidden(sub_name) + { + paths.push(sub.path()); + } + } + } + } + let mut files: Vec = paths + .into_iter() + .filter_map(|path| { + let content = fs::read_to_string(&path).ok()?; + let rel_path = path + .strip_prefix(dir) + .unwrap_or(&path) + .to_string_lossy() + .into_owned(); + Some(EnvFile { + rel_path, + vars: parse(&content), + }) + }) + .collect(); + files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path)); + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn parse_simple_pairs() { + let vars = parse("RESTIC_REPOSITORY=s3:example/repo\nRESTIC_PASSWORD=secret\n"); + assert_eq!(vars["RESTIC_REPOSITORY"], "s3:example/repo"); + assert_eq!(vars["RESTIC_PASSWORD"], "secret"); + } + + #[test] + fn parse_export_prefix() { + let vars = parse("export RESTIC_PASSWORD=secret\nexport SPACED=value\n"); + assert_eq!(vars["RESTIC_PASSWORD"], "secret"); + assert_eq!(vars["SPACED"], "value"); + } + + #[test] + fn parse_quoted_values() { + let vars = parse( + "DOUBLE=\"with spaces # not a comment\"\nSINGLE='also # kept'\nEMPTY=\"\"\n", + ); + assert_eq!(vars["DOUBLE"], "with spaces # not a comment"); + assert_eq!(vars["SINGLE"], "also # kept"); + assert_eq!(vars["EMPTY"], ""); + } + + #[test] + fn parse_comments_and_empty_lines() { + let vars = parse("# a comment\n\n \nKEY=value # trailing comment\n"); + assert_eq!(vars.len(), 1); + assert_eq!(vars["KEY"], "value"); + } + + #[test] + fn parse_hash_inside_unquoted_value_is_kept() { + let vars = parse("PASS=abc#def\n"); + assert_eq!(vars["PASS"], "abc#def"); + } + + #[test] + fn parse_value_containing_equals() { + let vars = parse("AWS_TOKEN=abc=def==\n"); + assert_eq!(vars["AWS_TOKEN"], "abc=def=="); + } + + #[test] + fn parse_ignores_garbage_lines() { + let vars = parse("no equals sign\n=emptykey\nOK=yes\n"); + assert_eq!(vars.len(), 1); + assert_eq!(vars["OK"], "yes"); + } + + #[test] + fn scan_finds_hidden_files_flat_and_in_hidden_subdirs() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fs::write(root.join(".env"), "A=1").unwrap(); + fs::write(root.join(".ble"), "A=2").unwrap(); + fs::write(root.join(".hetzner"), "A=3").unwrap(); + fs::write(root.join("backup.sh"), "A=4").unwrap(); // non-hidden: excluded + fs::write(root.join("backup.log"), "A=5").unwrap(); // non-hidden: excluded + fs::write(root.join("excludes.txt"), "A=6").unwrap(); // non-hidden: excluded + + // hidden subdir, one level deep: included + let work = root.join(".work"); + fs::create_dir(&work).unwrap(); + fs::write(work.join(".env"), "B=1").unwrap(); + fs::write(work.join(".token"), "B=2").unwrap(); + fs::write(work.join("notes.txt"), "B=3").unwrap(); // non-hidden: excluded + + // visible subdir: excluded + let visible = root.join("sub"); + fs::create_dir(&visible).unwrap(); + fs::write(visible.join(".env"), "C=1").unwrap(); + + // two levels deep: excluded + let deep = work.join(".deep"); + fs::create_dir(&deep).unwrap(); + fs::write(deep.join(".env"), "D=1").unwrap(); + + let files = scan(root).unwrap(); + let rels: Vec<&str> = files.iter().map(|f| f.rel_path.as_str()).collect(); + assert_eq!( + rels, + vec![".ble", ".env", ".hetzner", ".work/.env", ".work/.token"] + ); + assert_eq!(files[1].vars["A"], "1"); + assert_eq!(files[3].vars["B"], "1"); + } + + #[test] + fn repository_helper() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join(".env"), "RESTIC_REPOSITORY=/backup\n").unwrap(); + let files = scan(tmp.path()).unwrap(); + assert_eq!(files[0].repository(), "/backup"); + + fs::write(tmp.path().join(".env"), "OTHER=1\n").unwrap(); + let files = scan(tmp.path()).unwrap(); + assert_eq!(files[0].repository(), "(no RESTIC_REPOSITORY)"); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..af94011 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,211 @@ +//! resticview — a TUI browser for restic backup repositories. + +mod app; +mod envfile; +mod restic; +mod tree; +mod ui; + +use std::io::{self, stdout}; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, Result}; +use clap::Parser; +use crossterm::event::{ + self, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind, +}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::Constraint; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, List, ListItem, ListState}; +use ratatui::{Terminal, layout::Layout}; + +use crate::app::App; +use crate::envfile::EnvFile; + +#[derive(Parser)] +#[command( + name = "resticview", + version, + about = "TUI browser for restic backup repositories" +)] +struct Cli { + /// Directory containing restic env files (hidden dotfiles, also in hidden subdirs) + #[arg(long, value_name = "DIR")] + env_dir: Option, + + /// Show the env file picker even if only one env file exists + #[arg(long)] + select: bool, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let env_dir = cli.env_dir.unwrap_or_else(|| { + dirs::home_dir() + .expect("could not determine home directory") + .join(".restic") + }); + let files = envfile::scan(&env_dir) + .with_context(|| format!("failed to read env dir {}", env_dir.display()))?; + if files.is_empty() { + eprintln!( + "resticview: no env files found in {} (expected hidden files like .env, also inside hidden subdirectories)", + env_dir.display() + ); + std::process::exit(1); + } + let selected = if files.len() == 1 && !cli.select { + Some(0) + } else { + run_picker(&files)? + }; + let Some(idx) = selected else { + return Ok(()); // aborted in the picker + }; + let env_file = files.into_iter().nth(idx).expect("index from picker"); + if env_file.vars.is_empty() { + eprintln!( + "resticview: {} contains no KEY=VALUE pairs", + env_file.rel_path + ); + std::process::exit(1); + } + run_tui(env_file) +} + +/// Restore the terminal on drop, whatever happens. +struct TerminalGuard; + +impl TerminalGuard { + fn new() -> io::Result { + enable_raw_mode()?; + execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?; + Ok(Self) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(stdout(), LeaveAlternateScreen, crossterm::event::DisableMouseCapture); + } +} + +/// Env file picker: shown when multiple env files exist (or with --select). +/// Returns the chosen index, or None when aborted. +fn run_picker(files: &[EnvFile]) -> Result> { + let _guard = TerminalGuard::new()?; + let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; + let mut state = ListState::default().with_selected(Some(0)); + let len = files.len(); + loop { + terminal.draw(|frame| { + let chunks = Layout::vertical([Constraint::Min(3), Constraint::Length(1)]) + .split(frame.area()); + let items: Vec = files + .iter() + .map(|f| { + ListItem::new(Line::from(vec![ + Span::styled( + f.rel_path.clone(), + Style::default().fg(Color::Yellow), + ), + Span::raw(" "), + Span::styled( + f.get("RESTIC_REPOSITORY").unwrap_or("").to_string(), + Style::default().fg(Color::Gray), + ), + ])) + }) + .collect(); + let list = List::new(items) + .block(Block::bordered().title("Select repository")) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + frame.render_stateful_widget(list, chunks[0], &mut state); + frame.render_widget( + ratatui::widgets::Paragraph::new("Enter select · q/Esc quit") + .style(Style::default().fg(Color::DarkGray)), + chunks[1], + ); + })?; + if event::poll(Duration::from_millis(200))? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + let cur = state.selected().unwrap_or(0); + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(None), + KeyCode::Enter => return Ok(state.selected()), + KeyCode::Char('j') | KeyCode::Down => { + state.select(Some((cur + 1).min(len - 1))); + } + KeyCode::Char('k') | KeyCode::Up => { + state.select(Some(cur.saturating_sub(1))); + } + KeyCode::Char('g') => state.select(Some(0)), + KeyCode::Char('G') => state.select(Some(len - 1)), + KeyCode::Char('c') + if key + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) => + { + return Ok(None) + } + _ => {} + } + } + Event::Mouse(mouse) => { + let cur = state.selected().unwrap_or(0); + match mouse.kind { + MouseEventKind::ScrollUp => { + state.select(Some(cur.saturating_sub(1))); + } + MouseEventKind::ScrollDown => { + state.select(Some((cur + 1).min(len - 1))); + } + _ => {} + } + } + _ => {} + } + } + } +} + +fn run_tui(env_file: EnvFile) -> Result<()> { + let _guard = TerminalGuard::new()?; + let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; + terminal.clear().context("failed to clear terminal")?; + let mut app = App::new(env_file); + loop { + terminal + .draw(|frame| ui::draw(frame, &mut app)) + .context("failed to draw")?; + app.drain_messages(); + if event::poll(Duration::from_millis(80))? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => app.on_key(key), + Event::Mouse(mouse) => app.on_mouse(mouse.kind), + Event::Resize(_, _) => {} // next draw picks up the new size + _ => {} + } + } + app.tick = app.tick.wrapping_add(1); + if app.should_quit { + app.cancel_background(); + break; + } + } + Ok(()) +} diff --git a/src/restic.rs b/src/restic.rs new file mode 100644 index 0000000..4b42857 --- /dev/null +++ b/src/restic.rs @@ -0,0 +1,272 @@ +//! Blocking wrappers around the restic CLI. +//! +//! All functions run restic as a child process with the parsed env-file +//! variables, capture stderr and surface it in the error message. They are +//! meant to be called from background threads. + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use serde::Deserialize; + +/// One entry of `restic snapshots --json`. +/// +/// `short_id` is unique within a repository and accepted by all restic +/// commands, so it is used both for display and as the command argument. +#[derive(Debug, Clone, Deserialize)] +pub struct Snapshot { + pub short_id: String, + pub time: String, +} + +/// One line of `restic ls --json` (either the snapshot header or a node). +/// Node names are derived from the path, so `name` is not modelled. +#[derive(Debug, Clone, Deserialize)] +pub struct LsLine { + pub struct_type: String, + #[serde(rename = "type", default)] + pub node_type: Option, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub size: Option, +} + +impl LsLine { + pub fn is_node(&self) -> bool { + self.struct_type == "node" + } + + pub fn is_dir(&self) -> bool { + self.node_type.as_deref() == Some("dir") + } +} + +/// Result of `restic stats --json` (default mode). +#[derive(Debug, Clone, Deserialize)] +pub struct Stats { + pub total_size: u64, + pub total_file_count: u64, +} + +fn base_command(env: &HashMap) -> Command { + let mut cmd = Command::new("restic"); + cmd.envs(env); + cmd +} + +fn stderr_error(stderr: &[u8]) -> anyhow::Error { + let text = String::from_utf8_lossy(stderr).trim().to_string(); + if text.is_empty() { + anyhow!("restic failed without an error message") + } else { + anyhow!("{text}") + } +} + +/// `restic snapshots --json`, sorted newest first. +pub fn snapshots(env: &HashMap) -> Result> { + let out = base_command(env) + .args(["snapshots", "--json"]) + .output() + .context("failed to run restic (is it installed and on PATH?)")?; + if !out.status.success() { + return Err(stderr_error(&out.stderr)); + } + let text = String::from_utf8_lossy(&out.stdout); + let text = text.trim(); + if text.is_empty() { + return Ok(Vec::new()); + } + let mut parsed: Option> = + serde_json::from_str(text).context("failed to parse restic snapshots output")?; + let mut snaps = parsed.take().unwrap_or_default(); + snaps.sort_by(|a, b| b.time.cmp(&a.time)); + Ok(snaps) +} + +/// `restic ls --json`, streamed line by line. When `cancel` is set, +/// the child process is killed and an error is returned. +pub fn ls(env: &HashMap, snapshot: &str, cancel: &AtomicBool) -> Result> { + let mut child = base_command(env) + .args(["ls", snapshot, "--json"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to run restic (is it installed and on PATH?)")?; + let stdout = child.stdout.take().expect("stdout was piped"); + let mut lines = BufReader::new(stdout).lines(); + let mut out = Vec::new(); + loop { + if cancel.load(Ordering::Relaxed) { + let _ = child.kill(); + let _ = child.wait(); + return Err(anyhow!("cancelled")); + } + match lines.next() { + Some(Ok(line)) => { + if line.trim().is_empty() { + continue; + } + let parsed: LsLine = serde_json::from_str(&line) + .context("failed to parse restic ls output")?; + out.push(parsed); + } + Some(Err(e)) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(e).context("failed to read restic ls output"); + } + None => break, + } + } + let status = child.wait().context("failed to wait for restic")?; + if !status.success() { + let mut err = String::new(); + if let Some(mut stderr) = child.stderr.take() { + let _ = stderr.read_to_string(&mut err); + } + return Err(stderr_error(err.as_bytes())); + } + Ok(out) +} + +/// `restic stats --json` (default mode). +pub fn stats(env: &HashMap) -> Result { + let out = base_command(env) + .args(["stats", "--json"]) + .output() + .context("failed to run restic (is it installed and on PATH?)")?; + if !out.status.success() { + return Err(stderr_error(&out.stderr)); + } + let text = String::from_utf8_lossy(&out.stdout); + serde_json::from_str(text.trim()).context("failed to parse restic stats output") +} + +/// `restic restore --target [--include ]`. +/// +/// Polls the child so a running restore can be cancelled via `cancel` +/// (the child is killed). Returns restic's stdout on success. +pub fn restore( + env: &HashMap, + snapshot: &str, + target: &Path, + include: Option<&str>, + cancel: &AtomicBool, +) -> Result { + let mut cmd = base_command(env); + cmd.arg("restore") + .arg(snapshot) + .arg("--target") + .arg(target); + if let Some(include) = include { + cmd.arg("--include").arg(include); + } + let mut child = cmd + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to run restic (is it installed and on PATH?)")?; + let status = loop { + if let Some(status) = child.try_wait().context("failed to wait for restic")? { + break status; + } + if cancel.load(Ordering::Relaxed) { + let _ = child.kill(); + let _ = child.wait(); + return Err(anyhow!("restore cancelled")); + } + std::thread::sleep(Duration::from_millis(50)); + }; + let mut stdout = String::new(); + let mut stderr = String::new(); + if let Some(mut out) = child.stdout.take() { + let _ = out.read_to_string(&mut stdout); + } + if let Some(mut err) = child.stderr.take() { + let _ = err.read_to_string(&mut stderr); + } + if !status.success() { + return Err(stderr_error(stderr.as_bytes())); + } + Ok(stdout.trim().to_string()) +} + +/// Expand a leading `~/` (or bare `~`) to the user's home directory. +pub fn expand_tilde(path: &str) -> std::path::PathBuf { + let home = dirs::home_dir(); + match (home, path.strip_prefix("~/")) { + (Some(home), Some(rest)) => home.join(rest), + (Some(home), None) if path == "~" => home, + _ => std::path::PathBuf::from(path), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_snapshot_json() { + // Real restic output carries many more fields; serde ignores them. + let json = r#"[{"time":"2026-08-15T21:02:18.148309+02:00","paths":["/Users/seth"],"hostname":"mac.local","username":"seth","id":"46dd2015bcdb","short_id":"46dd2015"}]"#; + let snaps: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(snaps[0].short_id, "46dd2015"); + assert_eq!(snaps[0].time, "2026-08-15T21:02:18.148309+02:00"); + } + + #[test] + fn parse_ls_lines() { + let header: LsLine = serde_json::from_str( + r#"{"time":"2026-08-15T21:02:18.148309+02:00","id":"x","short_id":"y","struct_type":"snapshot"}"#, + ) + .unwrap(); + assert!(!header.is_node()); + + let dir: LsLine = serde_json::from_str( + r#"{"name":"Users","type":"dir","path":"/Users","uid":0,"struct_type":"node"}"#, + ) + .unwrap(); + assert!(dir.is_node()); + assert!(dir.is_dir()); + assert_eq!(dir.path.as_deref(), Some("/Users")); + assert_eq!(dir.size, None); + + let file: LsLine = serde_json::from_str( + r#"{"name":".zshrc","type":"file","path":"/Users/seth/.zshrc","size":1234,"struct_type":"node"}"#, + ) + .unwrap(); + assert!(file.is_node()); + assert!(!file.is_dir()); + assert_eq!(file.size, Some(1234)); + } + + #[test] + fn parse_stats_json() { + // `snapshots_count` (present since restic 0.19) is ignored. + let stats: Stats = serde_json::from_str( + r#"{"total_size":16942067259,"total_file_count":294549,"snapshots_count":6}"#, + ) + .unwrap(); + assert_eq!(stats.total_size, 16942067259); + assert_eq!(stats.total_file_count, 294549); + } + + #[test] + fn tilde_expansion() { + let home = dirs::home_dir().unwrap(); + assert_eq!(expand_tilde("~/foo/bar"), home.join("foo/bar")); + assert_eq!(expand_tilde("~"), home); + assert_eq!(expand_tilde("/abs/path"), std::path::PathBuf::from("/abs/path")); + assert_eq!( + expand_tilde("relative/path"), + std::path::PathBuf::from("relative/path") + ); + } +} diff --git a/src/tree.rs b/src/tree.rs new file mode 100644 index 0000000..9022429 --- /dev/null +++ b/src/tree.rs @@ -0,0 +1,333 @@ +//! Folder tree built from `restic ls --json` node lines. +//! +//! Nodes live in a flat arena (`Vec`, index 0 is the virtual root) and +//! reference each other by index, which keeps expand/collapse and navigation +//! free of borrow checker gymnastics. `visible` is the flattened list of rows +//! (node index + depth) currently shown; it is rebuilt whenever the expansion +//! state changes, never per frame. + +use crate::restic::LsLine; + +#[derive(Debug)] +pub struct Node { + pub name: String, + pub path: String, + pub is_dir: bool, + pub size: Option, + pub expanded: bool, + pub parent: Option, + pub children: Vec, +} + +#[derive(Debug)] +pub struct Tree { + pub nodes: Vec, + /// Flattened visible rows: (node index, depth). Root itself is not shown. + pub visible: Vec<(usize, usize)>, + /// Index into `visible`. + pub selected: usize, +} + +impl Tree { + pub fn build(lines: &[LsLine]) -> Tree { + let mut tree = Tree { + nodes: vec![Node { + name: String::new(), + path: String::new(), + is_dir: true, + size: None, + expanded: true, + parent: None, + children: Vec::new(), + }], + visible: Vec::new(), + selected: 0, + }; + for line in lines { + if !line.is_node() { + continue; + } + let Some(path) = &line.path else { continue }; + tree.insert(path, line.is_dir(), line.size); + } + sort_recursive(&mut tree.nodes, 0); + tree.rebuild_visible(); + tree + } + + fn insert(&mut self, path: &str, is_dir: bool, size: Option) { + let comps: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect(); + if comps.is_empty() { + return; + } + let mut current = 0usize; + let mut current_path = String::new(); + for (i, comp) in comps.iter().enumerate() { + current_path.push('/'); + current_path.push_str(comp); + let last = i + 1 == comps.len(); + let next = match find_child(&self.nodes, current, comp) { + Some(idx) => idx, + None => { + let idx = self.nodes.len(); + self.nodes.push(Node { + name: comp.to_string(), + path: current_path.clone(), + is_dir: if last { is_dir } else { true }, + size: None, + expanded: false, + parent: Some(current), + children: Vec::new(), + }); + self.nodes[current].children.push(idx); + idx + } + }; + if last { + self.nodes[next].is_dir = is_dir; + self.nodes[next].size = size; + } + current = next; + } + } + + pub fn rebuild_visible(&mut self) { + self.visible.clear(); + let children = self.nodes[0].children.clone(); + for child in children { + self.push_visible(child, 0); + } + if self.visible.is_empty() { + self.selected = 0; + } else if self.selected >= self.visible.len() { + self.selected = self.visible.len() - 1; + } + } + + fn push_visible(&mut self, idx: usize, depth: usize) { + self.visible.push((idx, depth)); + if self.nodes[idx].expanded { + let children = self.nodes[idx].children.clone(); + for child in children { + self.push_visible(child, depth + 1); + } + } + } + + pub fn move_by(&mut self, delta: i64) { + if self.visible.is_empty() { + return; + } + let next = (self.selected as i64 + delta).clamp(0, self.visible.len() as i64 - 1); + self.selected = next as usize; + } + + pub fn first(&mut self) { + self.selected = 0; + } + + pub fn last(&mut self) { + if !self.visible.is_empty() { + self.selected = self.visible.len() - 1; + } + } + + fn selected_idx(&self) -> Option { + self.visible.get(self.selected).map(|&(idx, _)| idx) + } + + pub fn selected_node(&self) -> Option<&Node> { + self.selected_idx().map(|idx| &self.nodes[idx]) + } + + pub fn toggle_selected(&mut self) { + if let Some(idx) = self.selected_idx() + && self.nodes[idx].is_dir + { + self.nodes[idx].expanded = !self.nodes[idx].expanded; + self.rebuild_visible(); + } + } + + pub fn expand_selected(&mut self) { + if let Some(idx) = self.selected_idx() + && self.nodes[idx].is_dir + && !self.nodes[idx].expanded + { + self.nodes[idx].expanded = true; + self.rebuild_visible(); + } + } + + /// Collapse the selected dir, or jump to the parent node when the + /// selection is a file or an already-collapsed dir. + pub fn collapse_selected(&mut self) { + let Some(idx) = self.selected_idx() else { + return; + }; + if self.nodes[idx].is_dir && self.nodes[idx].expanded { + self.nodes[idx].expanded = false; + self.rebuild_visible(); + } else if let Some(parent) = self.nodes[idx].parent + && parent != 0 + && let Some(pos) = self.visible.iter().position(|&(i, _)| i == parent) + { + self.selected = pos; + } + } +} + +fn find_child(nodes: &[Node], parent: usize, name: &str) -> Option { + nodes[parent] + .children + .iter() + .find(|&&idx| nodes[idx].name == name) + .copied() +} + +/// Sort children recursively: directories first, then by name (case-insensitive). +fn sort_recursive(nodes: &mut [Node], idx: usize) { + let mut children = std::mem::take(&mut nodes[idx].children); + children.sort_by(|&a, &b| { + (!nodes[a].is_dir) + .cmp(&(!nodes[b].is_dir)) + .then_with(|| nodes[a].name.to_lowercase().cmp(&nodes[b].name.to_lowercase())) + }); + for &child in &children { + sort_recursive(nodes, child); + } + nodes[idx].children = children; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(_name: &str, node_type: &str, path: &str, size: Option) -> LsLine { + LsLine { + struct_type: "node".to_string(), + node_type: Some(node_type.to_string()), + path: Some(path.to_string()), + size, + } + } + + fn header() -> LsLine { + LsLine { + struct_type: "snapshot".to_string(), + node_type: None, + path: None, + size: None, + } + } + + fn sample_lines() -> Vec { + vec![ + header(), + node("Users", "dir", "/Users", None), + node("seth", "dir", "/Users/seth", None), + node(".zshrc", "file", "/Users/seth/.zshrc", Some(1234)), + node("Documents", "dir", "/Users/seth/Documents", None), + node("a.txt", "file", "/Users/seth/Documents/a.txt", Some(10)), + node("b.txt", "file", "/Users/seth/Documents/b.txt", Some(20)), + node("etc", "dir", "/etc", None), + node("hosts", "file", "/etc/hosts", Some(100)), + ] + } + + #[test] + fn builds_tree_from_ls_lines() { + let tree = Tree::build(&sample_lines()); + // root children: /etc and /Users (dirs first, alphabetical) + let root_children = &tree.nodes[0].children; + assert_eq!(root_children.len(), 2); + assert_eq!(tree.nodes[root_children[0]].name, "etc"); + assert_eq!(tree.nodes[root_children[1]].name, "Users"); + + let users = root_children[1]; + assert!(tree.nodes[users].is_dir); + let seth = tree.nodes[users].children[0]; + assert_eq!(tree.nodes[seth].name, "seth"); + // seth children: Documents (dir) before .zshrc (file) + let seth_children = &tree.nodes[seth].children; + assert_eq!(tree.nodes[seth_children[0]].name, "Documents"); + assert_eq!(tree.nodes[seth_children[1]].name, ".zshrc"); + assert_eq!(tree.nodes[seth_children[1]].size, Some(1234)); + assert!(!tree.nodes[seth_children[1]].is_dir); + } + + #[test] + fn creates_implicit_intermediate_dirs() { + let lines = vec![node("deep.txt", "file", "/a/b/c/deep.txt", Some(1))]; + let tree = Tree::build(&lines); + let a = tree.nodes[0].children[0]; + assert!(tree.nodes[a].is_dir); + let b = tree.nodes[a].children[0]; + assert_eq!(tree.nodes[b].path, "/a/b"); + assert!(tree.nodes[b].is_dir); + } + + #[test] + fn visible_rows_follow_expansion() { + let mut tree = Tree::build(&sample_lines()); + // initially only root children are visible + let names: Vec<&str> = tree + .visible + .iter() + .map(|&(idx, _)| tree.nodes[idx].name.as_str()) + .collect(); + assert_eq!(names, vec!["etc", "Users"]); + + // expand /Users + tree.selected = 1; + tree.expand_selected(); + let names: Vec<&str> = tree + .visible + .iter() + .map(|&(idx, _)| tree.nodes[idx].name.as_str()) + .collect(); + assert_eq!(names, vec!["etc", "Users", "seth"]); + let depths: Vec = tree.visible.iter().map(|&(_, d)| d).collect(); + assert_eq!(depths, vec![0, 0, 1]); + + // collapse /Users again via toggle + tree.toggle_selected(); + assert_eq!(tree.visible.len(), 2); + } + + #[test] + fn collapse_jumps_to_parent() { + let mut tree = Tree::build(&sample_lines()); + tree.selected = 1; // Users + tree.expand_selected(); + tree.selected = 2; // seth (collapsed dir) -> collapse jumps to parent Users + tree.collapse_selected(); + assert_eq!(tree.selected, 1); + assert_eq!(tree.selected_node().unwrap().name, "Users"); + } + + #[test] + fn movement_is_clamped() { + let mut tree = Tree::build(&sample_lines()); + tree.move_by(-5); + assert_eq!(tree.selected, 0); + tree.move_by(100); + assert_eq!(tree.selected, tree.visible.len() - 1); + tree.last(); + assert_eq!(tree.selected, 1); + tree.first(); + assert_eq!(tree.selected, 0); + } + + #[test] + fn empty_snapshot_has_no_rows() { + let lines = vec![header()]; + let mut tree = Tree::build(&lines); + assert!(tree.visible.is_empty()); + assert!(tree.selected_node().is_none()); + // navigation on empty tree must not panic + tree.move_by(1); + tree.toggle_selected(); + tree.collapse_selected(); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..e9804ce --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,398 @@ +//! Ratatui rendering: three side-by-side panels, status line, key hints and +//! the restore prompt/confirmation popups. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap}; + +use crate::app::{App, Focus, LoadState, RestoreState, TreeStatus}; + +pub fn draw(frame: &mut Frame, app: &mut App) { + let area = frame.area(); + let chunks = Layout::vertical([ + Constraint::Min(3), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + let cols = Layout::horizontal([ + Constraint::Percentage(25), + Constraint::Percentage(45), + Constraint::Percentage(30), + ]) + .split(chunks[0]); + + draw_snapshots(frame, app, cols[0]); + draw_tree(frame, app, cols[1]); + draw_stats(frame, app, cols[2]); + draw_status(frame, app, chunks[1]); + draw_footer(frame, chunks[2]); + + match &app.restore_state { + RestoreState::Input { .. } => draw_input_popup(frame, app), + RestoreState::Confirm { .. } => draw_confirm_popup(frame, app), + _ => {} + } +} + +fn panel_block(title: String, focused: bool) -> Block<'static> { + let style = if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + Block::bordered().title(title).border_style(style) +} + +fn placeholder(block: Block<'static>, text: String, style: Style) -> Paragraph<'static> { + Paragraph::new(text).block(block).style(style) +} + +fn draw_snapshots(frame: &mut Frame, app: &mut App, area: Rect) { + let focused = app.focus == Focus::Snapshots; + match &app.snapshots { + LoadState::Loading => { + let block = panel_block("Snapshots".to_string(), focused); + let text = format!("{} loading snapshots…", app.spinner()); + frame.render_widget(placeholder(block, text, Style::default().fg(Color::Gray)), area); + } + LoadState::Failed(e) => { + let block = panel_block("Snapshots — error".to_string(), focused); + let text = format!("{e}\n\npress R to retry"); + frame.render_widget( + placeholder(block, text, Style::default().fg(Color::Red)).wrap(Wrap { trim: false }), + area, + ); + } + LoadState::Ready(snaps) => { + let block = panel_block(format!("Snapshots ({})", snaps.len()), focused); + if snaps.is_empty() { + frame.render_widget( + placeholder(block, "no snapshots".to_string(), Style::default().fg(Color::Gray)), + area, + ); + return; + } + let items: Vec = snaps + .iter() + .map(|s| { + ListItem::new(Line::from(vec![ + Span::styled( + s.short_id.clone(), + Style::default().fg(Color::Yellow), + ), + Span::raw(" "), + Span::styled(format_time(&s.time), Style::default().fg(Color::Gray)), + ])) + }) + .collect(); + let list = List::new(items).block(block).highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ); + frame.render_stateful_widget(list, area, &mut app.snap_state); + } + } +} + +fn draw_tree(frame: &mut Frame, app: &mut App, area: Rect) { + let focused = app.focus == Focus::Tree; + let title = match &app.tree { + Some((sid, _)) => format!("Files — {sid}"), + None => "Files".to_string(), + }; + let block = panel_block(title, focused); + match &app.tree_status { + TreeStatus::Loading => { + let text = format!("{} loading files…", app.spinner()); + frame.render_widget(placeholder(block, text, Style::default().fg(Color::Gray)), area); + } + TreeStatus::Failed(e) => { + let text = format!("{e}\n\npress R to retry"); + frame.render_widget( + placeholder(block, text, Style::default().fg(Color::Red)).wrap(Wrap { trim: false }), + area, + ); + } + TreeStatus::Idle => match &app.tree { + None => { + frame.render_widget( + placeholder( + block, + "no snapshot selected".to_string(), + Style::default().fg(Color::Gray), + ), + area, + ); + } + Some((_, tree)) if tree.visible.is_empty() => { + frame.render_widget( + placeholder(block, "empty snapshot".to_string(), Style::default().fg(Color::Gray)), + area, + ); + } + Some((_, tree)) => { + let items: Vec = tree + .visible + .iter() + .map(|&(idx, depth)| { + let node = &tree.nodes[idx]; + let indent = " ".repeat(depth); + let icon = if node.is_dir { + if node.expanded { "▾ " } else { "▸ " } + } else { + " " + }; + let name_style = if node.is_dir { + Style::default().fg(Color::Blue) + } else { + Style::default() + }; + let name = if node.is_dir { + format!("{}/", node.name) + } else { + node.name.clone() + }; + let mut spans = vec![ + Span::raw(format!("{indent}{icon}")), + Span::styled(name, name_style), + ]; + if let Some(size) = node.size + && !node.is_dir + { + spans.push(Span::styled( + format!(" {}", human_size(size)), + Style::default().fg(Color::DarkGray), + )); + } + ListItem::new(Line::from(spans)) + }) + .collect(); + let mut state = ListState::default().with_selected(Some(tree.selected)); + let list = List::new(items).block(block).highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ); + frame.render_stateful_widget(list, area, &mut state); + } + }, + } +} + +fn draw_stats(frame: &mut Frame, app: &App, area: Rect) { + let focused = app.focus == Focus::Stats; + let block = panel_block("Repository".to_string(), focused); + let label = Style::default().fg(Color::Gray); + let mut lines = vec![ + Line::from(vec![ + Span::styled("Repository: ", label), + Span::raw(app.repo.clone()), + ]), + Line::from(vec![ + Span::styled("Env file: ", label), + Span::raw(app.env_rel_path.clone()), + ]), + Line::from(""), + ]; + match &app.snapshots { + LoadState::Ready(snaps) => lines.push(Line::from(vec![ + Span::styled("Snapshots: ", label), + Span::raw(snaps.len().to_string()), + ])), + LoadState::Loading => lines.push(Line::from(vec![ + Span::styled("Snapshots: ", label), + Span::raw(format!("{} loading…", app.spinner())), + ])), + LoadState::Failed(_) => lines.push(Line::from(vec![ + Span::styled("Snapshots: ", label), + Span::styled("error", Style::default().fg(Color::Red)), + ])), + } + match &app.stats { + LoadState::Loading => lines.push(Line::from(vec![ + Span::styled("Stats: ", label), + Span::raw(format!("{} loading…", app.spinner())), + ])), + LoadState::Failed(e) => { + lines.push(Line::from(vec![ + Span::styled("Stats: ", label), + Span::styled("error", Style::default().fg(Color::Red)), + ])); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + e.clone(), + Style::default().fg(Color::Red), + ))); + } + LoadState::Ready(stats) => { + lines.push(Line::from(vec![ + Span::styled("Total size: ", label), + Span::raw(human_size(stats.total_size)), + ])); + lines.push(Line::from(vec![ + Span::styled("Files: ", label), + Span::raw(format_count(stats.total_file_count)), + ])); + } + } + frame.render_widget( + Paragraph::new(lines).block(block).wrap(Wrap { trim: false }), + area, + ); +} + +fn draw_status(frame: &mut Frame, app: &App, area: Rect) { + let (text, style) = if matches!(app.restore_state, RestoreState::Running) { + ( + format!("{} restore running…", app.spinner()), + Style::default().fg(Color::Yellow), + ) + } else if let Some((ok, msg)) = &app.restore_outcome { + let style = if *ok { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Red) + }; + (msg.clone(), style) + } else if let Some(status) = &app.status { + (status.clone(), Style::default().fg(Color::Gray)) + } else { + (String::new(), Style::default()) + }; + frame.render_widget(Paragraph::new(text).style(style), area); +} + +fn draw_footer(frame: &mut Frame, area: Rect) { + let hints = "q quit · Tab focus · j/k/↑/↓ scroll · Enter/h/l tree · r restore · R refresh · g/G top/bottom"; + frame.render_widget( + Paragraph::new(hints).style(Style::default().fg(Color::DarkGray)), + area, + ); +} + +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + Rect { + x: area.x + area.width.saturating_sub(width) / 2, + y: area.y + area.height.saturating_sub(height) / 2, + width: width.min(area.width), + height: height.min(area.height), + } +} + +fn popup_block(title: &str) -> Block<'static> { + Block::bordered() + .title(title.to_string()) + .border_style(Style::default().fg(Color::Yellow)) +} + +fn draw_input_popup(frame: &mut Frame, app: &App) { + let RestoreState::Input { input, cursor, .. } = &app.restore_state else { + return; + }; + let area = frame.area(); + let popup = centered_rect(60.min(area.width.saturating_sub(4)), 3, area); + frame.render_widget(Clear, popup); + let block = popup_block("Restore to directory"); + let inner = block.inner(popup); + frame.render_widget(block, popup); + frame.render_widget(Paragraph::new(input.as_str()), inner); + let cursor_x = inner.x + (*cursor as u16).min(inner.width.saturating_sub(1)); + frame.set_cursor_position((cursor_x, inner.y)); +} + +fn draw_confirm_popup(frame: &mut Frame, app: &App) { + let RestoreState::Confirm { + target, + snapshot, + include, + } = &app.restore_state + else { + return; + }; + let area = frame.area(); + let popup = centered_rect(70.min(area.width.saturating_sub(4)), 8, area); + frame.render_widget(Clear, popup); + let block = popup_block("Confirm restore"); + let mut lines = vec![Line::from(format!("Snapshot: {snapshot}"))]; + if let Some(include) = include { + lines.push(Line::from(format!("Path: {include}"))); + } + lines.push(Line::from(format!("Target: {target}"))); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("[y]", Style::default().fg(Color::Green)), + Span::raw(" restore "), + Span::styled("[n]/Esc", Style::default().fg(Color::Red)), + Span::raw(" cancel"), + ])); + frame.render_widget(Paragraph::new(lines).block(block), popup); +} + +/// "2026-08-15T21:02:18.148309+02:00" -> "2026-08-15 21:02:18" +pub fn format_time(raw: &str) -> String { + if raw.len() >= 19 { + format!("{} {}", &raw[..10], &raw[11..19]) + } else { + raw.to_string() + } +} + +pub fn human_size(bytes: u64) -> String { + const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + if bytes < 1024 { + return format!("{bytes} B"); + } + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + format!("{value:.1} {}", UNITS[unit]) +} + +pub fn format_count(n: u64) -> String { + let s = n.to_string(); + let mut out = String::new(); + for (i, c) in s.chars().enumerate() { + if i > 0 && (s.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn time_formatting() { + assert_eq!( + format_time("2026-08-15T21:02:18.148309+02:00"), + "2026-08-15 21:02:18" + ); + assert_eq!(format_time("short"), "short"); + } + + #[test] + fn human_sizes() { + assert_eq!(human_size(0), "0 B"); + assert_eq!(human_size(512), "512 B"); + assert_eq!(human_size(1024), "1.0 KiB"); + assert_eq!(human_size(16942067259), "15.8 GiB"); + assert_eq!(human_size(3158099665), "2.9 GiB"); + } + + #[test] + fn count_formatting() { + assert_eq!(format_count(294549), "294,549"); + assert_eq!(format_count(6), "6"); + assert_eq!(format_count(1000), "1,000"); + } +}