From 16a5a4327ef75847edf482b19119ffaaf9ffe7db Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 22 Aug 2026 09:26:15 +0200 Subject: [PATCH] dap-tui: ratatui device-sync dashboard Full rewrite of dap-sync as a Rust TUI on ratatui: - Tokyo Night theme, gradient title rule, rounded panels - Devices sidebar with live mount status + last-sync age - Dashboard / Sync / Logs / Config tabs - Live sync engine: rclone mirror, NFC-aware diff, rsync progress parsing, podkit + mlove (fatsort) finalize - Abort-safe subprocess handling, persisted sync state --- .gitignore | 1 + Cargo.lock | 1955 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 20 + LICENSE | 21 + README.md | 91 ++ src/app.rs | 286 ++++++ src/config.rs | 381 +++++++ src/engine.rs | 776 ++++++++++++++ src/main.rs | 55 + src/progress.rs | 225 +++++ src/ui/components.rs | 400 ++++++++ src/ui/mod.rs | 108 ++ src/ui/tabs/config_view.rs | 84 ++ src/ui/tabs/dashboard.rs | 213 ++++ src/ui/tabs/logs.rs | 91 ++ src/ui/tabs/mod.rs | 4 + src/ui/tabs/sync.rs | 246 +++++ src/ui/theme.rs | 15 + 18 files changed, 4972 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/app.rs create mode 100644 src/config.rs create mode 100644 src/engine.rs create mode 100644 src/main.rs create mode 100644 src/progress.rs create mode 100644 src/ui/components.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/tabs/config_view.rs create mode 100644 src/ui/tabs/dashboard.rs create mode 100644 src/ui/tabs/logs.rs create mode 100644 src/ui/tabs/mod.rs create mode 100644 src/ui/tabs/sync.rs create mode 100644 src/ui/theme.rs 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..65f1d9c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1955 @@ +# 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 = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[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 = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[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 = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[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 = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "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 = "dap-tui" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "ratatui", + "serde", + "toml", + "unicode-normalization", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +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.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[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 = "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 = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[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 = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[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 = "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 = "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 = "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_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[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 = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "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 = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[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-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[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-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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 = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1ac8c78 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dap-tui" +version = "0.1.0" +edition = "2021" +description = "Device sync for portable players — TUI edition (ratatui)" +license = "MIT" + +[dependencies] +ratatui = "0.30" +toml = "0.8" +serde = { version = "1", features = ["derive"] } +chrono = "0.4" +unicode-normalization = "0.1" +dirs = "6" +anyhow = "1" + +[profile.release] +lto = true +opt-level = 3 +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a4bf177 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sebastian + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3cbce2 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# dap-tui + +A terminal UI for device sync of portable music players, rewritten from scratch +in Rust on top of [ratatui](https://ratatui.rs/). + +It mirrors the StorageBox masters library into your local music directory, then +syncs a device profile — the same pipeline as the Python `dap-sync`, now as a +living dashboard instead of a batch CLI. + +## Highlights + +- **Tokyo Night** palette, rounded panels, gradient title rule, status & key + bars. +- **Devices sidebar** — live mount status (green dot = mounted), firmware tags, + last-sync age per device. +- **4 tabs** — Dashboard (device + library cards, source-chain diagram), Sync + (live step pipeline + progress gauge), Logs (color-coded, follow mode), + Config (syntax-coloured TOML). +- **Live sync engine** — streams `rclone` and `rsync` output, parses progress, + NFC-aware diffing for macOS→FAT32/Android devices, optional `fatsort` + finalize for Mlove devices. +- **Abort-safe** — `a` kills the running child process cleanly. +- Reads the existing `~/.config/dap-sync/config.toml` (compatible with + `dap-sync`), persists last-sync state to `~/.local/share/dap-sync/sync-state.toml`. + +## Install / run + +```bash +cargo build --release +./target/release/dap-tui # uses ~/.config/dap-sync/config.toml +./target/release/dap-tui --config /path/to/config.toml +./target/release/dap-tui --skip-mirror # skip the StorageBox → local mirror step +``` + +## Keybindings + +| Key | Action | +| ------------ | ---------------------------- | +| `↑`/`↓` `j`/`k` | move selection | +| `s` / `Enter`| sync selected device | +| `a` | abort running sync | +| `Tab` / `1-4`| switch tab | +| `r` | refresh device mounts | +| `f` | toggle log follow | +| `u` / `d` | scroll logs | +| `x` | clear logs | +| `?` / `h` | help overlay | +| `q` / `Esc` | quit | + +## Configuration + +`dap-tui` reads the standard `dap-sync` config format: + +```toml +[paths] +local_music_dir = "~/Music" +local_hoerspiele_dir = "~/hoerspiele" + +[storagebox] +source = "storagebox:masters" +exclude = ["/Music/**", ".DS_Store", ".localized"] + +[[devices]] +name = "iPod Rockbox" +firmware = "rockbox" +mount_point = "/media/sebastian/IPOD" +music_folder = "Music" +``` + +Firmware types: `apple` (podkit command), `rockbox`, `android` (rsync), +`mlove` (rsync + `fatsort`). Devices with `content_type = "hoerspiele"` sync +against the hoerspiele library. + +## How the sync pipeline works + +1. **Mirror** — `rclone sync` (or local `rsync`) pulls the StorageBox source + into the local library. +2. **Diff** — the device is scanned and compared to the source with + NFC-normalised paths, size, and 2-second-rounded mtimes (FAT32 friendly). +3. **Sync** — stale files are removed first, then `rsync --files-from` copies + only what changed, with live per-file progress. +4. **Finalize** (mlove) — unmounts the volume and runs `fatsort` via + `sudo -n` to keep FAT directory order stable. If passwordless sudo is not + available the step is reported as a warning and the commands are printed. + +## Development + +```bash +cargo test # parser + config tests +cargo run +``` diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..c1f2eb3 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,286 @@ +use crate::config::{Config, Device}; +use crate::engine::{Engine, EngineEvent, LogLevel}; +use crate::ui; +use anyhow::Result; +use ratatui::crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, +}; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tab { + Dashboard = 0, + Sync = 1, + Logs = 2, + Config = 3, +} + +impl Tab { + pub const ALL: [Tab; 4] = [Tab::Dashboard, Tab::Sync, Tab::Logs, Tab::Config]; + pub fn label(&self) -> &'static str { + match self { + Tab::Dashboard => "Dashboard", + Tab::Sync => "Sync", + Tab::Logs => "Logs", + Tab::Config => "Config", + } + } + pub fn number(&self) -> usize { + match self { + Tab::Dashboard => 1, + Tab::Sync => 2, + Tab::Logs => 3, + Tab::Config => 4, + } + } +} + +pub struct App { + pub config: Config, + pub engine: Engine, + pub skip_mirror: bool, + + pub tab: Tab, + pub selected: usize, + pub sidebar_scroll: usize, + + pub logs: Vec, + pub log_follow: bool, + pub log_scroll: usize, + + pub frame: u64, + pub should_quit: bool, + pub show_help: bool, + pub status_msg: Option<(String, LogLevel)>, +} + +impl App { + pub fn new(config: Config, skip_mirror: bool) -> Result { + let engine = Engine::spawn(); + Ok(App { + config, + engine, + skip_mirror, + tab: Tab::Dashboard, + selected: 0, + sidebar_scroll: 0, + logs: Vec::new(), + log_follow: true, + log_scroll: 0, + frame: 0, + should_quit: false, + show_help: false, + status_msg: None, + }) + } + + pub fn selected_device(&self) -> Option<&Device> { + self.config.devices.get(self.selected) + } + + pub fn spinner(&self) -> &'static str { + const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + FRAMES[(self.frame as usize / 3) % FRAMES.len()] + } + + pub fn is_syncing(&self) -> bool { + self.engine.running() + } + + fn refresh_mounts(&mut self) { + for d in self.config.devices.iter_mut() { + d.mounted = d + .mount_point + .as_ref() + .map(|p| p.exists()) + .unwrap_or(false); + } + } + + fn drain_engine(&mut self) { + while let Ok(ev) = self.engine.events.try_recv() { + match ev { + EngineEvent::Log(entry) => { + let is_error = matches!(entry.level, LogLevel::Error); + self.logs.push(entry); + if self.logs.len() > 2000 { + let overflow = self.logs.len() - 2000; + self.logs.drain(0..overflow); + } + if is_error { + self.status_msg = Some(( + "see Logs tab (3) for details".to_string(), + LogLevel::Error, + )); + } + } + EngineEvent::Changed => {} + EngineEvent::Finished { ok, summary } => { + let level = if ok { LogLevel::Success } else { LogLevel::Error }; + self.status_msg = Some((summary.clone(), level)); + } + } + } + } + + fn start_sync(&mut self) { + let Some(device) = self.selected_device().cloned() else { + self.status_msg = Some(("no device selected".to_string(), LogLevel::Warn)); + return; + }; + if self.is_syncing() { + return; + } + self.status_msg = None; + let cfg = Arc::new(self.config.clone()); + self.engine + .start_sync(device, cfg, self.skip_mirror); + } + + fn abort_sync(&mut self) { + if self.is_syncing() { + self.engine.abort_sync(); + self.status_msg = Some(("aborting…".to_string(), LogLevel::Warn)); + } + } + + pub fn on_key(&mut self, key: KeyEvent) { + if key.kind != KeyEventKind::Press { + return; + } + if self.show_help { + if matches!(key.code, KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?')) { + self.show_help = false; + } + return; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + KeyCode::Char('?') | KeyCode::Char('h') => self.show_help = true, + KeyCode::Char('j') | KeyCode::Down => self.move_selection(1), + KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1), + KeyCode::Char('g') => { + self.selected = 0; + self.sidebar_scroll = 0; + } + KeyCode::Char('G') => { + let n = self.config.devices.len().saturating_sub(1); + self.selected = n; + self.ensure_selection_visible(); + } + KeyCode::Char('s') | KeyCode::Enter => self.start_sync(), + KeyCode::Char('a') => self.abort_sync(), + KeyCode::Char('r') => { + self.refresh_mounts(); + self.status_msg = Some(("devices refreshed".to_string(), LogLevel::Info)); + } + KeyCode::Char('f') => { + self.log_follow = !self.log_follow; + self.log_scroll = 0; + } + KeyCode::Char('x') => { + self.logs.clear(); + self.log_scroll = 0; + } + KeyCode::Char('u') => self.scroll_logs(-10), + KeyCode::Char('d') => self.scroll_logs(10), + KeyCode::Tab => self.tab = next_tab(self.tab), + KeyCode::BackTab => self.tab = prev_tab(self.tab), + KeyCode::Char('1') => self.tab = Tab::Dashboard, + KeyCode::Char('2') => self.tab = Tab::Sync, + KeyCode::Char('3') => self.tab = Tab::Logs, + KeyCode::Char('4') => self.tab = Tab::Config, + _ => {} + } + } + + fn move_selection(&mut self, delta: i32) { + if self.config.devices.is_empty() { + return; + } + let n = self.config.devices.len(); + self.selected = ((self.selected as i32 + delta).rem_euclid(n as i32)) as usize; + self.ensure_selection_visible(); + } + + fn ensure_selection_visible(&mut self) { + const VISIBLE: usize = 12; + if self.selected < self.sidebar_scroll { + self.sidebar_scroll = self.selected; + } + if self.selected >= self.sidebar_scroll + VISIBLE { + self.sidebar_scroll = self.selected + 1 - VISIBLE; + } + } + + fn scroll_logs(&mut self, delta: i32) { + self.log_follow = false; + let max = self.logs.len().saturating_sub(1) as i32; + self.log_scroll = ((self.log_scroll as i32 + delta).clamp(0, max)) as usize; + } + + fn tick(&mut self) { + self.frame += 1; + if self.frame % 30 == 0 { + self.refresh_mounts(); + } + if self.is_syncing() { + let aborted = self.engine.abort.load(Ordering::Relaxed); + if let Ok(mut s) = self.engine.state.lock() { + s.aborting = aborted; + } + } + } + + pub fn run(&mut self) -> Result<()> { + let mut terminal = ratatui::init(); + let res = (|| -> Result<()> { + loop { + self.drain_engine(); + self.tick(); + terminal.draw(|f| ui::draw(f, self))?; + if self.should_quit { + break; + } + if event::poll(Duration::from_millis(40))? { + if let Event::Key(key) = event::read()? { + self.on_key(key); + } + } + } + Ok(()) + })(); + ratatui::restore(); + res + } +} + +fn next_tab(t: Tab) -> Tab { + Tab::ALL[(t as usize + 1) % Tab::ALL.len()] +} +fn prev_tab(t: Tab) -> Tab { + Tab::ALL[(t as usize + Tab::ALL.len() - 1) % Tab::ALL.len()] +} + +pub fn relative_ago(dt: chrono::DateTime) -> String { + let now = chrono::Local::now(); + let secs = (now - dt).num_seconds().max(0); + if secs < 5 { + "just now".to_string() + } else if secs < 60 { + format!("{secs}s ago") + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..92fccd7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,381 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Local}; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +pub const DEFAULT_CONFIG_FILE: &str = "~/.config/dap-sync/config.toml"; +pub const DEFAULT_SYNC_COMMAND: &str = "podkit sync -c main"; +pub const DEFAULT_STORAGEBOX_SOURCE: &str = "storagebox:masters"; +pub const DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE: &str = "storagebox:backup/hoerspiele"; +pub const DEFAULT_EXCLUDES: &[&str] = &["/Music/**", ".DS_Store", ".localized"]; + +fn expand_home(s: &str) -> PathBuf { + if let Some(rest) = s.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + PathBuf::from(s) +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +struct RawPaths { + local_music_dir: Option, + local_hoerspiele_dir: Option, + output_dir: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +struct RawStoragebox { + source: Option, + hoerspiele_source: Option, + exclude: Option>, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +struct RawSync { + command: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +struct RawDevice { + name: Option, + firmware: Option, + content_type: Option, + sync_command: Option, + mount_point: Option, + music_folder: Option, + cache_dir: Option, + preserve_fat_order: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +struct RawConfig { + paths: RawPaths, + storagebox: RawStoragebox, + sync: RawSync, + devices: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Firmware { + Apple, + Rockbox, + Android, + Mlove, +} + +impl Firmware { + pub fn from_str(s: &str) -> Firmware { + match s.trim().to_lowercase().as_str() { + "rockbox" => Firmware::Rockbox, + "android" => Firmware::Android, + "mlove" => Firmware::Mlove, + _ => Firmware::Apple, + } + } + pub fn label(&self) -> &'static str { + match self { + Firmware::Apple => "apple", + Firmware::Rockbox => "rockbox", + Firmware::Android => "android", + Firmware::Mlove => "mlove", + } + } +} + +#[derive(Debug, Clone)] +pub struct Device { + pub name: String, + pub firmware: Firmware, + pub content_type: String, + pub sync_command: String, + pub mount_point: Option, + pub music_folder: String, + pub preserve_fat_order: bool, + pub mounted: bool, + pub last_sync: Option>, + pub last_status: String, +} + +impl Device { + pub fn mount_str(&self) -> String { + match &self.mount_point { + Some(p) => p.display().to_string(), + None => "—".to_string(), + } + } +} + +#[derive(Debug, Clone)] +pub struct Config { + pub config_file: PathBuf, + pub local_music_dir: PathBuf, + pub local_hoerspiele_dir: PathBuf, + pub output_dir: PathBuf, + pub storagebox_source: String, + pub storagebox_hoerspiele_source: String, + pub storagebox_excludes: Vec, + pub devices: Vec, + pub raw_text: String, +} + +impl Config { + fn refresh_mounts(&mut self) { + for d in self.devices.iter_mut() { + d.mounted = d + .mount_point + .as_ref() + .map(|p| p.exists()) + .unwrap_or(false); + } + } + + fn apply_state(&mut self, state: &StateStore) { + for d in self.devices.iter_mut() { + if let Some(rec) = state.records.get(&d.name) { + d.last_sync = rec.last_sync; + d.last_status = rec.status.clone(); + } + } + } +} + +pub struct StateStore { + pub records: BTreeMap, +} + +#[derive(Debug, Clone)] +pub struct StateRecord { + pub last_sync: Option>, + pub status: String, +} + +fn default_config_path() -> PathBuf { + expand_home(DEFAULT_CONFIG_FILE) +} + +pub fn load(config_path: Option) -> Result { + let path = match config_path { + Some(p) => expand_home(&p), + None => default_config_path(), + }; + + let mut cfg = if path.exists() { + let text = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + let raw: RawConfig = toml::from_str(&text) + .with_context(|| format!("failed to parse config at {}", path.display()))?; + let local_music = raw + .paths + .local_music_dir + .map(|s| expand_home(&s)) + .unwrap_or_else(|| expand_home("~/Music")); + let local_hoerspiele = raw + .paths + .local_hoerspiele_dir + .map(|s| expand_home(&s)) + .unwrap_or_else(|| expand_home("~/hoerspiele")); + let output_dir = raw + .paths + .output_dir + .map(|s| expand_home(&s)) + .unwrap_or_else(|| expand_home("~/.local/share/dap-sync")); + let sync_cmd = raw + .sync + .command + .clone() + .unwrap_or_else(|| DEFAULT_SYNC_COMMAND.to_string()); + + let mut devices = Vec::new(); + for rd in raw.devices.iter().filter(|d| d.name.is_some()) { + let name = rd.name.clone().unwrap(); + let firmware = Firmware::from_str(rd.firmware.as_deref().unwrap_or("apple")); + let content_type = rd + .content_type + .clone() + .unwrap_or_else(|| "music".to_string()) + .trim() + .to_lowercase(); + let sync_command = rd + .sync_command + .clone() + .unwrap_or_else(|| sync_cmd.clone()); + let mount_point = rd + .mount_point + .clone() + .map(|s| expand_home(&s)); + let music_folder = rd + .music_folder + .clone() + .unwrap_or_else(|| { + match firmware { + Firmware::Mlove => "Hörspiele".to_string(), + _ => "Music".to_string(), + } + }); + let preserve_fat_order = rd + .preserve_fat_order + .unwrap_or_else(|| firmware == Firmware::Mlove); + + devices.push(Device { + name, + firmware, + content_type, + sync_command, + mount_point, + music_folder, + preserve_fat_order, + mounted: false, + last_sync: None, + last_status: String::new(), + }); + } + + Config { + config_file: path.clone(), + local_music_dir: local_music, + local_hoerspiele_dir: local_hoerspiele, + output_dir, + storagebox_source: raw + .storagebox + .source + .unwrap_or_else(|| DEFAULT_STORAGEBOX_SOURCE.to_string()), + storagebox_hoerspiele_source: raw + .storagebox + .hoerspiele_source + .unwrap_or_else(|| DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE.to_string()), + storagebox_excludes: raw + .storagebox + .exclude + .map(|v| v.clone()) + .unwrap_or_else(|| DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect()), + devices, + raw_text: text, + } + } else { + Config { + config_file: path.clone(), + local_music_dir: expand_home("~/Music"), + local_hoerspiele_dir: expand_home("~/hoerspiele"), + output_dir: expand_home("~/.local/share/dap-sync"), + storagebox_source: DEFAULT_STORAGEBOX_SOURCE.to_string(), + storagebox_hoerspiele_source: DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE.to_string(), + storagebox_excludes: DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect(), + devices: Vec::new(), + raw_text: "# no config file found\n".to_string(), + } + }; + + cfg.refresh_mounts(); + let store = load_state(&cfg.output_dir); + cfg.apply_state(&store); + + Ok(cfg) +} + +pub fn state_file(output_dir: &Path) -> PathBuf { + output_dir.join("sync-state.toml") +} + +pub fn load_state(output_dir: &Path) -> StateStore { + let file = state_file(output_dir); + let mut records = BTreeMap::new(); + if let Ok(text) = fs::read_to_string(&file) { + if let Ok(table) = text.parse::() { + for (name, val) in table.iter() { + if let Some(sub) = val.as_table() { + let last_sync = sub + .get("last_sync") + .and_then(|v| v.as_str()) + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Local)); + let status = sub + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + records.insert(name.clone(), StateRecord { last_sync, status }); + } + } + } + } + StateStore { records } +} + +pub fn save_state( + output_dir: &Path, + name: &str, + last_sync: DateTime, + status: &str, +) { + let file = state_file(output_dir); + let mut table: toml::Table = fs::read_to_string(&file) + .ok() + .and_then(|t| t.parse::().ok()) + .unwrap_or_default(); + + let mut sub = toml::map::Map::new(); + sub.insert("last_sync".into(), toml::Value::String(last_sync.to_rfc3339())); + sub.insert("status".into(), toml::Value::String(status.to_string())); + table.insert(name.into(), toml::Value::Table(sub)); + + if let Some(parent) = file.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(text) = toml::to_string(&table) { + let _ = fs::write(&file, text); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expands_home() { + let p = expand_home("~/foo/bar"); + assert_eq!(p, dirs::home_dir().unwrap().join("foo/bar")); + assert_eq!(expand_home("/abs/path"), PathBuf::from("/abs/path")); + } + + #[test] + fn loads_devices_from_config() { + let dir = std::env::temp_dir().join("dap-tui-config-test"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("config.toml"); + std::fs::write( + &path, + r#" +[paths] +local_music_dir = "~/Music" + +[storagebox] +source = "storagebox:masters" +exclude = [".DS_Store"] + +[[devices]] +name = "Test iPod" +firmware = "rockbox" +mount_point = "/media/ipod" +music_folder = "Music" +"#, + ) + .unwrap(); + let cfg = load(Some(path.display().to_string())).unwrap(); + assert_eq!(cfg.devices.len(), 1); + let d = &cfg.devices[0]; + assert_eq!(d.name, "Test iPod"); + assert_eq!(d.firmware.label(), "rockbox"); + assert_eq!(d.music_folder, "Music"); + assert_eq!(cfg.storagebox_excludes, vec![".DS_Store"]); + assert_eq!(cfg.local_music_dir, dirs::home_dir().unwrap().join("Music")); + } +} diff --git a/src/engine.rs b/src/engine.rs new file mode 100644 index 0000000..eedfe63 --- /dev/null +++ b/src/engine.rs @@ -0,0 +1,776 @@ +use crate::config::{self, Config, Device, Firmware}; +use crate::progress::{parse_rclone_stats, parse_rsync_line, RsyncLine}; +use chrono::Local; +use std::collections::HashMap; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread; +use unicode_normalization::UnicodeNormalization; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + Info, + Transfer, + Warn, + Error, + Success, +} + +#[derive(Debug, Clone)] +pub struct LogEntry { + pub level: LogLevel, + pub msg: String, + pub ts: chrono::DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepStatus { + Pending, + Active, + Done, + Error, +} + +#[derive(Debug, Clone)] +pub struct StepState { + pub title: String, + pub detail: String, + pub status: StepStatus, +} + +#[derive(Debug, Clone, Default)] +pub struct ProgressInfo { + pub pct: u16, + pub done: String, + pub total: String, + pub speed: String, + pub eta: String, + pub current: String, +} + +#[derive(Debug, Clone)] +pub struct SyncState { + pub running: bool, + pub aborting: bool, + pub device: Option, + pub steps: Vec, + pub mirror: ProgressInfo, + pub device_prog: ProgressInfo, + pub result: Option, + pub summary: String, +} + +impl Default for SyncState { + fn default() -> Self { + SyncState { + running: false, + aborting: false, + device: None, + steps: Vec::new(), + mirror: ProgressInfo::default(), + device_prog: ProgressInfo::default(), + result: None, + summary: String::new(), + } + } +} + +#[derive(Debug, Clone)] +pub enum EngineEvent { + Log(LogEntry), + Changed, + Finished { + ok: bool, + summary: String, + }, +} + +struct WorkerCtx { + state: Arc>, + tx: mpsc::Sender, + abort: Arc, +} + +impl WorkerCtx { + fn log(&self, level: LogLevel, msg: impl Into) { + let _ = self.tx.send(EngineEvent::Log(LogEntry { + level, + msg: msg.into(), + ts: Local::now(), + })); + } + + fn changed(&self) { + let _ = self.tx.send(EngineEvent::Changed); + } + + fn set_step(&self, idx: usize, status: StepStatus, detail: impl Into) { + if let Ok(mut s) = self.state.lock() { + if let Some(step) = s.steps.get_mut(idx) { + step.status = status; + step.detail = detail.into(); + } + } + self.changed(); + } + + fn set_mirror(&self, p: crate::progress::RcloneStats) { + if let Ok(mut s) = self.state.lock() { + s.mirror.pct = p.pct; + s.mirror.done = p.done; + s.mirror.total = p.total; + s.mirror.speed = p.speed; + s.mirror.eta = p.eta; + } + self.changed(); + } + + fn set_device_progress(&self, done: u64, total: u64, speed: &str, eta: &str, current: &str) { + if let Ok(mut s) = self.state.lock() { + let p = &mut s.device_prog; + p.done = done.to_string(); + p.total = total.to_string(); + p.speed = speed.to_string(); + p.eta = eta.to_string(); + if !current.is_empty() { + p.current = current.to_string(); + } + if total > 0 { + p.pct = ((done as f64 / total as f64) * 100.0) as u16; + } + } + self.changed(); + } +} + +pub struct Engine { + pub state: Arc>, + pub events: mpsc::Receiver, + tx: mpsc::Sender, + pub abort: Arc, +} + +impl Engine { + pub fn spawn() -> Engine { + let (tx, events) = mpsc::channel(); + Engine { + state: Arc::new(Mutex::new(SyncState::default())), + events, + tx, + abort: Arc::new(AtomicBool::new(false)), + } + } + + pub fn running(&self) -> bool { + self.state.lock().map(|s| s.running).unwrap_or(false) + } + + pub fn start_sync(&self, device: Device, config: Arc, skip_mirror: bool) { + if self.running() { + return; + } + self.abort.store(false, Ordering::Relaxed); + let ctx = Arc::new(WorkerCtx { + state: self.state.clone(), + tx: self.tx.clone(), + abort: self.abort.clone(), + }); + thread::spawn(move || run_sync(config, device, skip_mirror, ctx)); + } + + pub fn abort_sync(&self) { + self.abort.store(true, Ordering::Relaxed); + } +} + +// ── sync pipeline ──────────────────────────────────────────────────────────── + +fn build_steps(device: &Device) -> Vec { + let mut steps = vec![ + StepState { + title: "Mirror StorageBox".to_string(), + detail: "refresh local library".to_string(), + status: StepStatus::Pending, + }, + StepState { + title: format!("Sync {}", device.name), + detail: format!("{} · {}", device.firmware.label(), device.content_type), + status: StepStatus::Pending, + }, + ]; + if device.firmware == Firmware::Mlove { + steps.push(StepState { + title: "Finalize FAT".to_string(), + detail: "unmount + fatsort".to_string(), + status: StepStatus::Pending, + }); + } + steps +} + +fn run_sync(config: Arc, device: Device, skip_mirror: bool, ctx: Arc) { + let name = device.name.clone(); + { + let mut s = ctx.state.lock().unwrap(); + *s = SyncState::default(); + s.running = true; + s.device = Some(name.clone()); + s.steps = build_steps(&device); + s.summary = "Starting sync…".to_string(); + } + ctx.changed(); + ctx.log(LogLevel::Info, format!("── Starting sync for {}", name)); + + let (ok, summary) = match run_pipeline(&config, &device, skip_mirror, &ctx) { + Ok(()) => (true, "Sync complete".to_string()), + Err(e) => { + ctx.log(LogLevel::Error, e.clone()); + (false, e) + } + }; + + { + let mut s = ctx.state.lock().unwrap(); + s.running = false; + s.aborting = false; + s.result = Some(ok); + s.summary = summary.clone(); + for step in s.steps.iter_mut() { + if step.status == StepStatus::Active { + step.status = if ok { StepStatus::Done } else { StepStatus::Error }; + } + } + } + ctx.log( + if ok { LogLevel::Success } else { LogLevel::Error }, + if ok { format!("✓ {summary}") } else { format!("✗ {summary}") }, + ); + ctx.changed(); + let _ = ctx.tx.send(EngineEvent::Finished { + ok, + summary: summary.clone(), + }); + + config::save_state(&config.output_dir, &name, Local::now(), if ok { "ok" } else { "error" }); +} + +fn run_pipeline( + config: &Config, + device: &Device, + skip_mirror: bool, + ctx: &WorkerCtx, +) -> Result<(), String> { + let steps = build_steps(device); + + if !skip_mirror { + ctx.set_step(0, StepStatus::Active, "refreshing local library…"); + run_mirror(config, device, ctx)?; + ctx.set_step(0, StepStatus::Done, "local library refreshed"); + } else { + ctx.set_step(0, StepStatus::Done, "skipped (--skip-mirror)"); + } + + if ctx.abort.load(Ordering::Relaxed) { + return Err("aborted".to_string()); + } + + ctx.set_step(1, StepStatus::Active, "connecting to device…"); + match device.firmware { + Firmware::Apple => run_podkit(config, device, ctx)?, + _ => run_rsync_device(config, device, ctx)?, + } + ctx.set_step(1, StepStatus::Done, "device synced"); + + if device.firmware == Firmware::Mlove { + if ctx.abort.load(Ordering::Relaxed) { + return Err("aborted".to_string()); + } + ctx.set_step(2, StepStatus::Active, "unmounting + running fatsort…"); + if let Err(e) = finalize_fat(device, ctx) { + ctx.log(LogLevel::Warn, format!("finalize skipped: {e}")); + ctx.set_step( + 2, + StepStatus::Error, + "device synced, but eject/fatsort needs to run manually", + ); + } else { + ctx.set_step(2, StepStatus::Done, "device ejected & sorted"); + } + } + + let _ = steps; + Ok(()) +} + +// ── step 1: mirror StorageBox → local library ──────────────────────────────── + +fn run_mirror(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), String> { + let (source, local_dir, label) = if device.content_type == "hoerspiele" { + ( + config.storagebox_hoerspiele_source.clone(), + config.local_hoerspiele_dir.clone(), + "Hörspiele", + ) + } else { + ( + config.storagebox_source.clone(), + config.local_music_dir.clone(), + "Music", + ) + }; + + fs::create_dir_all(&local_dir).map_err(|e| format!("cannot create {}: {e}", local_dir.display()))?; + + let src_path = PathBuf::from(&source); + if src_path.is_absolute() && src_path.is_dir() { + ctx.log( + LogLevel::Info, + format!("Mirroring {label} from local source {} → {}", source, local_dir.display()), + ); + let args = vec![ + "-a".to_string(), + "--delete".to_string(), + "--info=progress2,name1".to_string(), + "--exclude=.DS_Store".to_string(), + "--exclude=.localized".to_string(), + format!("{}/", source), + local_dir.display().to_string(), + ]; + return stream(ctx, "rsync", &args, |line| { + if let Some(l) = parse_rsync_line(line) { + match l { + RsyncLine::Progress(p) => { + if let Ok(mut s) = ctx.state.lock() { + s.mirror.pct = p.pct; + s.mirror.done = p.done.to_string(); + s.mirror.total = p.total.to_string(); + s.mirror.speed = p.speed.clone(); + s.mirror.eta = p.eta.clone(); + } + true + } + RsyncLine::Total(_) | RsyncLine::File(_) => true, + } + } else { + false + } + }); + } + + ctx.log( + LogLevel::Info, + format!("Mirroring {label} via rclone {} → {}", source, local_dir.display()), + ); + let mut args = vec![ + "sync".to_string(), + source.clone(), + local_dir.display().to_string(), + ]; + for pat in &config.storagebox_excludes { + args.push("--exclude".to_string()); + args.push(pat.clone()); + } + args.extend( + [ + "--create-empty-src-dirs", + "--stats", + "1s", + "--stats-one-line", + "--stats-log-level", + "NOTICE", + ] + .iter() + .map(|s| s.to_string()), + ); + + stream(ctx, "rclone", &args, |line| { + if let Some(p) = parse_rclone_stats(line) { + ctx.set_mirror(p); + true + } else { + false + } + }) +} + +// ── step 2: device sync ─────────────────────────────────────────────────────── + +fn run_podkit(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), String> { + let cmd = device + .sync_command + .replace("{local_music_dir}", &config.local_music_dir.display().to_string()); + ctx.log(LogLevel::Info, format!("Running podkit: {cmd}")); + stream(ctx, "sh", &["-c".to_string(), cmd], |line| { + ctx.log(LogLevel::Info, line); + true // consumed — avoid double-logging in the outer loop + }) +} + +fn scan_music_dir(base: &Path) -> HashMap { + let mut map = HashMap::new(); + let mut stack = vec![base.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name_s = name.to_string_lossy().to_string(); + if name_s.starts_with('.') || name_s.starts_with("._") { + continue; + } + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + let rel = match path.strip_prefix(base) { + Ok(r) => r, + Err(_) => continue, + }; + let key = rel + .components() + .map(|c| c.as_os_str().to_string_lossy().nfc().collect::()) + .collect::>() + .join("/"); + if let Ok(meta) = entry.metadata() { + let size = meta.len(); + let mtime = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let mtime = mtime.div_euclid(2) * 2; // FAT32 2s resolution + map.insert(key, (size, mtime)); + } + } + } + map +} + +fn run_rsync_device(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), String> { + let mount = device + .mount_point + .as_ref() + .ok_or_else(|| "mount_point is not set for this device".to_string())?; + if !mount.exists() { + return Err(format!( + "device not found at {} — connect & mount it, then try again", + mount.display() + )); + } + + let (src_dir, label) = if device.content_type == "hoerspiele" { + (config.local_hoerspiele_dir.clone(), "Hörspiele") + } else { + (config.local_music_dir.clone(), "Music") + }; + let dest = mount.join(&device.music_folder); + fs::create_dir_all(&dest).map_err(|e| format!("cannot create {}: {e}", dest.display()))?; + + ctx.log(LogLevel::Info, format!("Scanning local {label} library…")); + let src_map = scan_music_dir(&src_dir); + ctx.log(LogLevel::Info, format!("Scanning device {}…", dest.display())); + let dst_map = scan_music_dir(&dest); + + let mut to_copy: Vec = Vec::new(); + for (k, (size, mtime)) in &src_map { + let changed = dst_map + .get(k) + .map(|(dsize, dmtime)| dsize != size || dmtime != mtime) + .unwrap_or(true); + if changed { + to_copy.push(k.clone()); + } + } + if device.preserve_fat_order { + to_copy.sort(); + } + let to_delete: Vec = dst_map.keys().filter(|k| !src_map.contains_key(*k)).cloned().collect(); + + ctx.log( + LogLevel::Info, + format!( + "{} source files · {} to copy · {} to remove", + src_map.len(), + to_copy.len(), + to_delete.len() + ), + ); + + if to_copy.is_empty() && to_delete.is_empty() { + ctx.log(LogLevel::Success, "Everything up to date."); + return Ok(()); + } + + if !to_delete.is_empty() { + remove_stale(&dest, &to_delete); + ctx.log(LogLevel::Info, format!("Removed {} stale file(s).", to_delete.len())); + } + + if !to_copy.is_empty() { + let tmp = write_file_list(&to_copy)?; + let res = { + let args = vec![ + "-t".to_string(), + "--files-from".to_string(), + tmp.clone(), + "--progress".to_string(), + "--exclude=.DS_Store".to_string(), + "--exclude=._*".to_string(), + format!("{}/", src_dir.display()), + dest.display().to_string(), + ]; + let total = to_copy.len() as u64; + stream(ctx, "rsync", &args, |line| { + match parse_rsync_line(line) { + Some(RsyncLine::Progress(p)) => { + let done = if p.done > 0 { p.done } else { total }; + ctx.set_device_progress(done, total, &p.speed, &p.eta, ""); + true + } + Some(RsyncLine::Total(_)) => true, + Some(RsyncLine::File(path)) => { + ctx.set_device_progress(0, total, "", "", &path); + ctx.log(LogLevel::Transfer, path); + true + } + None => false, + } + }) + }; + let _ = fs::remove_file(&tmp); + res?; + } + + Ok(()) +} + +fn remove_stale(dest: &Path, to_delete: &[String]) { + let mut actual: HashMap = HashMap::new(); + let mut stack = vec![dest.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name_s = name.to_string_lossy().to_string(); + if name_s.starts_with('.') || name_s.starts_with("._") { + continue; + } + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if let Ok(rel) = path.strip_prefix(dest) { + let key = rel + .components() + .map(|c| c.as_os_str().to_string_lossy().nfc().collect::()) + .collect::>() + .join("/"); + actual.insert(key, path); + } + } + } + for key in to_delete { + if let Some(p) = actual.get(key) { + if p.exists() { + let _ = fs::remove_file(p); + if let Some(parent) = p.parent() { + let _ = fs::remove_dir(parent); // remove if now empty + } + } + } + } +} + +fn write_file_list(items: &[String]) -> Result { + let path = std::env::temp_dir().join(format!("dap-tui-files-{}.txt", std::process::id())); + let content = items.iter().map(|s| s.as_str()).collect::>().join("\n"); + fs::write(&path, content).map_err(|e| format!("cannot write file list: {e}"))?; + Ok(path.display().to_string()) +} + +// ── mlove finalize ──────────────────────────────────────────────────────────── + +fn capture(cmd: &str, args: &[&str]) -> Result<(i32, String), String> { + let out = Command::new(cmd) + .args(args) + .stdin(Stdio::null()) + .output() + .map_err(|e| format!("cannot run {cmd}: {e}"))?; + let text = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Ok((out.status.code().unwrap_or(-1), text)) +} + +fn finalize_fat(device: &Device, ctx: &WorkerCtx) -> Result<(), String> { + let mount = device + .mount_point + .as_ref() + .ok_or_else(|| "mount_point is required to run fatsort".to_string())?; + + let target = resolve_fatsort_target(mount)?; + + ctx.log(LogLevel::Info, format!("Unmounting {mount_display}", mount_display = mount.display())); + let (code, detail) = capture("sudo", &["-n", "umount", mount.to_str().unwrap_or("")])?; + if code != 0 { + ctx.log( + LogLevel::Warn, + format!( + "sudo -n umount failed (exit {code}) {detail} — run manually: sudo umount {mount_display}", + mount_display = mount.display() + ), + ); + } + + ctx.log(LogLevel::Info, format!("Running fatsort on {target}…")); + let (code, detail) = capture("sudo", &["-n", "fatsort", &target])?; + if code != 0 { + return Err(format!( + "fatsort failed (exit {code}) {detail} — run manually: sudo fatsort {target}" + )); + } + Ok(()) +} + +fn resolve_fatsort_target(mount: &Path) -> Result { + if !mount.is_dir() { + return Ok(mount.display().to_string()); + } + let (code, out) = capture( + "findmnt", + &["-n", "-o", "SOURCE", "-T", mount.to_str().unwrap_or("")], + )?; + if code != 0 || out.is_empty() { + return Err(format!( + "could not resolve a block device for {}", + mount.display() + )); + } + Ok(out) +} + +// ── subprocess plumbing ─────────────────────────────────────────────────────── + +/// Run a command, streaming its output lines to `on_line`. Returns Err on +/// failure or abort. Lines returned `true` from `on_line` are treated as +/// consumed (progress); unconsumed lines are logged. +fn stream( + ctx: &WorkerCtx, + cmd: &str, + args: &[String], + mut on_line: F, +) -> Result<(), String> +where + F: FnMut(&str) -> bool, +{ + let mut child = Command::new(cmd) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("could not start {cmd}: {e}"))?; + + let mut stdout = child.stdout.take().unwrap(); + let mut stderr = child.stderr.take().unwrap(); + + let (err_tx, err_rx) = mpsc::channel::(); + let err_handle = thread::spawn(move || { + let mut buf = [0u8; 8192]; + let mut partial: Vec = Vec::new(); + loop { + match stderr.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + partial.extend_from_slice(&buf[..n]); + let mut pos = 0; + for (i, b) in partial.iter().enumerate() { + if *b == b'\n' || *b == b'\r' { + let line = String::from_utf8_lossy(&partial[pos..i]).trim().to_string(); + if !line.is_empty() { + let _ = err_tx.send(line); + } + pos = i + 1; + } + } + partial.drain(0..pos); + } + } + } + if !partial.is_empty() { + let line = String::from_utf8_lossy(&partial).trim().to_string(); + if !line.is_empty() { + let _ = err_tx.send(line); + } + } + }); + + let mut buf = [0u8; 8192]; + let mut partial: Vec = Vec::new(); + loop { + if ctx.abort.load(Ordering::Relaxed) { + let _ = child.kill(); + let _ = child.wait(); + return Err("aborted".to_string()); + } + match stdout.read(&mut buf) { + Ok(0) => break, + Err(_) => break, + Ok(n) => { + partial.extend_from_slice(&buf[..n]); + let mut pos = 0; + for (i, b) in partial.iter().enumerate() { + if *b == b'\n' || *b == b'\r' { + let line = String::from_utf8_lossy(&partial[pos..i]).trim().to_string(); + if !line.is_empty() && !on_line(&line) { + ctx.log(LogLevel::Info, line); + } + pos = i + 1; + } + } + partial.drain(0..pos); + } + } + } + if !partial.is_empty() { + let line = String::from_utf8_lossy(&partial).trim().to_string(); + if !line.is_empty() && !on_line(&line) { + ctx.log(LogLevel::Info, line); + } + } + + let _ = err_handle.join(); + for line in err_rx.try_iter() { + if !line.is_empty() && !on_line(&line) { + let lower = line.to_lowercase(); + let level = if lower.contains("error") || lower.contains("failed") { + LogLevel::Error + } else { + LogLevel::Info + }; + ctx.log(level, line); + } + } + + let status = child.wait().map_err(|e| format!("{cmd} failed: {e}"))?; + if ctx.abort.load(Ordering::Relaxed) { + return Err("aborted".to_string()); + } + if !status.success() { + return Err(format!( + "{cmd} failed (exit {})", + status.code().unwrap_or(-1) + )); + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..5e51376 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,55 @@ +mod app; +mod config; +mod engine; +mod progress; +mod ui; + +use anyhow::Result; + +fn print_help() { + println!("dap-tui — device sync TUI (ratatui)"); + println!(); + println!("Usage: dap-tui [OPTIONS]"); + println!(); + println!("Options:"); + println!(" --config use an alternate config file"); + println!(" -s, --skip-mirror skip the StorageBox → local mirror step"); + println!(" -V, --version print version"); + println!(" -h, --help show this help"); +} + +fn main() -> Result<()> { + let args: Vec = std::env::args().collect(); + let mut config_path: Option = None; + let mut skip_mirror = false; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "-h" | "--help" => { + print_help(); + return Ok(()); + } + "-V" | "--version" => { + println!("dap-tui {}", app::VERSION); + return Ok(()); + } + "--config" => { + i += 1; + if let Some(p) = args.get(i) { + config_path = Some(p.clone()); + } + } + "-s" | "--skip-mirror" => skip_mirror = true, + other if other.starts_with("--config=") => { + config_path = Some(other[9..].to_string()); + } + _ => {} + } + i += 1; + } + + let cfg = config::load(config_path)?; + let mut app = app::App::new(cfg, skip_mirror)?; + app.run() +} diff --git a/src/progress.rs b/src/progress.rs new file mode 100644 index 0000000..f16a674 --- /dev/null +++ b/src/progress.rs @@ -0,0 +1,225 @@ +/// Parsers for rclone and rsync progress output. Hand-rolled, no regex dep. + +const AUDIO_EXTS: &[&str] = &[".m4a", ".mp3", ".aac", ".ogg", ".wav", ".wv", ".ape", ".flac", ".m4b"]; + +#[derive(Debug, Clone, PartialEq)] +pub enum RsyncLine { + /// Total file count announced by rsync ("Transfer starting: N files") + Total(u64), + /// A per-file progress update + Progress(RsyncProgress), + /// A transferred audio file path (current file being handled) + File(String), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RsyncProgress { + pub done: u64, + pub total: u64, + pub pct: u16, + pub speed: String, + pub eta: String, +} + +fn ends_with_audio_ext(s: &str) -> bool { + let lower = s.to_ascii_lowercase(); + AUDIO_EXTS.iter().any(|e| lower.ends_with(e)) +} + +fn parse_u64(s: &str) -> Option { + s.chars() + .filter(|c| c.is_ascii_digit()) + .collect::() + .parse() + .ok() +} + +fn find_pct(s: &str) -> Option { + for tok in s.split_whitespace() { + if tok.ends_with('%') && tok.len() > 1 { + if let Some(p) = tok[..tok.len() - 1].parse::().ok() { + return Some(p.min(100)); + } + } + } + None +} + +fn find_speed(s: &str) -> String { + for tok in s.split_whitespace() { + if tok.ends_with("/s") && tok.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) { + return tok.to_string(); + } + } + String::new() +} + +fn find_eta(s: &str) -> String { + if let Some(idx) = s.find("ETA") { + let rest = s[idx + 3..].trim(); + if !rest.is_empty() { + return rest.to_string(); + } + } + String::new() +} + +/// Parse a single rsync progress line. +pub fn parse_rsync_line(line: &str) -> Option { + let line = line.trim(); + + if let Some(rest) = line.strip_prefix("Transfer starting:") { + if let Some(n) = parse_u64(rest) { + return Some(RsyncLine::Total(n)); + } + } + + // Per-file progress: " 512 100% 1.23MB/s 0:00:01 (xfer#12, to-check=38/200)" + if line.contains("xfer#") || line.contains("xfr#") || line.contains("to-check=") { + let mut done = 0; + let mut total = 0; + if let Some(idx) = line.find('#') { + // take the integer immediately after '#' + let rest: String = line[idx + 1..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if let Ok(n) = rest.parse::() { + done = n; + } + } + if let Some(idx) = line.find("to-check=") { + let rest = &line[idx + 9..]; + let mut it = rest.split(|c: char| c == '/' || c == ' ' || c == ')'); + let _remaining = it.next().and_then(parse_u64); + if let Some(b) = it.next().and_then(parse_u64) { + total = b; // "to-check=remaining/total" → total is second value + } + } else if let Some(idx) = line.find("to-chk=") { + let rest = &line[idx + 7..]; + let mut it = rest.split(|c: char| c == '/' || c == ' ' || c == ')'); + let _remaining = it.next().and_then(parse_u64); + if let Some(b) = it.next().and_then(parse_u64) { + total = b; + } + } + let pct = find_pct(line).unwrap_or(0); + return Some(RsyncLine::Progress(RsyncProgress { + done, + total, + pct, + speed: find_speed(line), + eta: find_eta(line), + })); + } + + // Plain audio file path line (transferred file being processed). + if ends_with_audio_ext(line) { + // Skip stats-looking lines that just happen to end in an audio ext. + if find_pct(line).is_none() && !line.starts_with(' ') { + return Some(RsyncLine::File(line.to_string())); + } + } + + None +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RcloneStats { + pub done: String, + pub total: String, + pub pct: u16, + pub speed: String, + pub eta: String, +} + +/// Parse a rclone `--stats-one-line` "Transferred:" line. +pub fn parse_rclone_stats(line: &str) -> Option { + let line = line.trim(); + if !line.contains(" / ") || !line.contains('%') { + return None; + } + let body = line + .split_once(':') + .map(|(_, rest)| rest.trim()) + .unwrap_or(line); + if !body.contains(" / ") { + return None; + } + let mut parts = body.split(','); + let sizes = parts.next()?.trim(); + let (done, total) = sizes.split_once('/')?; + let done = done.trim().to_string(); + let total = total.trim().to_string(); + + let pct = parts + .next() + .and_then(|p| { + let p = p.trim().trim_end_matches('%'); + p.parse::().ok() + }) + .unwrap_or(0) + .min(100); + + let speed = parts.next().map(|s| s.trim().to_string()).unwrap_or_default(); + + let eta = parts + .next() + .map(|s| s.trim().trim_start_matches("ETA").trim().to_string()) + .unwrap_or_default(); + + Some(RcloneStats { done, total, pct, speed, eta }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_rclone_stats_line() { + let s = parse_rclone_stats( + "Transferred: 512.000 MiB / 1.000 GiB, 50%, 25.00 MiB/s, ETA 20s", + ) + .unwrap(); + assert_eq!(s.done, "512.000 MiB"); + assert_eq!(s.total, "1.000 GiB"); + assert_eq!(s.pct, 50); + assert_eq!(s.speed, "25.00 MiB/s"); + assert_eq!(s.eta, "20s"); + } + + #[test] + fn rejects_non_stats_line() { + assert!(parse_rclone_stats("Elapsed time: 5.0s").is_none()); + } + + #[test] + fn parses_rsync_progress() { + let line = " 512 100% 1.23MB/s 0:00:01 (xfer#12, to-check=38/200)"; + match parse_rsync_line(line) { + Some(RsyncLine::Progress(p)) => { + assert_eq!(p.done, 12); + assert_eq!(p.total, 200); + assert_eq!(p.pct, 100); + assert_eq!(p.speed, "1.23MB/s"); + } + other => panic!("expected progress, got {other:?}"), + } + } + + #[test] + fn parses_rsync_total() { + match parse_rsync_line("Transfer starting: 150 files") { + Some(RsyncLine::Total(n)) => assert_eq!(n, 150), + other => panic!("expected total, got {other:?}"), + } + } + + #[test] + fn parses_rsync_file_line() { + match parse_rsync_line("Artist - Album/track1.mp3") { + Some(RsyncLine::File(p)) => assert_eq!(p, "Artist - Album/track1.mp3"), + other => panic!("expected file, got {other:?}"), + } + } +} diff --git a/src/ui/components.rs b/src/ui/components.rs new file mode 100644 index 0000000..c6ccccc --- /dev/null +++ b/src/ui/components.rs @@ -0,0 +1,400 @@ +use crate::app::{relative_ago, App, Tab}; +use crate::engine::{LogLevel, StepStatus}; +use crate::ui::theme; +use crate::ui::truncate; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +// ── title bar ───────────────────────────────────────────────────────────────── + +pub fn draw_title(f: &mut Frame, area: Rect, app: &App) { + let mut spans = vec![ + Span::styled( + " dap-tui ", + Style::default() + .fg(theme::BG) + .bg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("device sync", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::styled( + "StorageBox → local → player", + Style::default().fg(theme::DIM), + ), + ]; + + let mut right_spans = vec![ + Span::styled( + if app.is_syncing() { + format!("{} syncing", app.spinner()) + } else { + "● ready".to_string() + }, + Style::default().fg(if app.is_syncing() { theme::CYAN } else { theme::GREEN }), + ), + Span::raw(" "), + Span::styled( + truncate( + &app.config.config_file.display().to_string(), + (area.width.max(9) - 8) as usize, + ), + Style::default().fg(theme::DIM), + ), + ]; + + // pad to full width + let left_w: usize = spans.iter().map(|s| s.width()).sum(); + let right_w: usize = right_spans.iter().map(|s| s.width()).sum(); + let total = area.width as usize; + let pad = total.saturating_sub(left_w + right_w); + spans.push(Span::raw(" ".repeat(pad))); + spans.append(&mut right_spans); + + f.render_widget( + Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG_ALT)), + area, + ); +} + +// ── gradient rule ───────────────────────────────────────────────────────────── + +pub fn draw_rule(f: &mut Frame, area: Rect, _app: &App) { + let mut spans = Vec::with_capacity(area.width as usize); + let colors = [theme::ACCENT, theme::CYAN, theme::MAGENTA, theme::DIM]; + for i in 0..area.width { + let t = i as f32 / area.width.max(1) as f32; + let idx = if t < 0.25 { + 0 + } else if t < 0.5 { + 1 + } else if t < 0.75 { + 2 + } else { + 3 + }; + spans.push(Span::styled("─", Style::default().fg(colors[idx]))); + } + f.render_widget( + Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG)), + area, + ); +} + +// ── sidebar ─────────────────────────────────────────────────────────────────── + +pub fn draw_sidebar(f: &mut Frame, area: Rect, app: &App) { + let block = Block::default() + .borders(Borders::RIGHT) + .border_style(Style::default().fg(theme::DIM)) + .style(Style::default().bg(theme::BG_ALT)) + .title(Span::styled( + " DEVICES ", + Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD), + )); + let inner = block.inner(area); + f.render_widget(block, area); + + let [head, list, foot] = + Layout::vertical([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)]) + .areas(inner); + + let mounted = app.config.devices.iter().filter(|d| d.mounted).count(); + let syncing = app.engine.state.lock().map(|s| s.running).unwrap_or(false); + + let head_line = Line::from(vec![ + Span::styled( + format!("{} devices", app.config.devices.len()), + Style::default().fg(theme::FG), + ), + Span::raw(" · "), + Span::styled( + format!("{mounted} mounted"), + Style::default().fg(if mounted > 0 { theme::GREEN } else { theme::DIM }), + ), + ]); + f.render_widget( + Paragraph::new(head_line).style(Style::default().bg(theme::BG_ALT)), + head, + ); + + draw_device_list(f, list, app, syncing); + + let foot_line = Line::from(vec![ + Span::styled("s sync", Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD)), + Span::styled(" · ", Style::default().fg(theme::DIM)), + Span::styled("r refresh", Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD)), + if app.skip_mirror { + Span::styled(" · mirror off", Style::default().fg(theme::YELLOW)) + } else { + Span::styled("", Style::default()) + }, + ]); + f.render_widget( + Paragraph::new(foot_line).style(Style::default().bg(theme::BG_ALT)), + foot, + ); +} + +fn draw_device_list(f: &mut Frame, area: Rect, app: &App, syncing: bool) { + if app.config.devices.is_empty() { + f.render_widget( + Paragraph::new(Line::from(Span::styled( + " no devices configured", + Style::default().fg(theme::DIM), + ))) + .style(Style::default().bg(theme::BG_ALT)), + area, + ); + return; + } + + let item_h: u16 = 2; + let visible = (area.height / item_h) as usize; + let start = app.sidebar_scroll.min(app.config.devices.len()); + + let rows = Layout::vertical( + (0..visible) + .map(|_| Constraint::Length(item_h)) + .collect::>(), + ) + .split(area); + + let sync_device = if syncing { + app.engine.state.lock().unwrap().device.clone() + } else { + None + }; + + for (i, row) in rows.iter().enumerate() { + let idx = start + i; + let Some(device) = app.config.devices.get(idx) else { + continue; + }; + let selected = idx == app.selected; + + let row_style = if selected { + Style::default() + .bg(theme::ACCENT) + .fg(theme::BG) + } else { + Style::default().bg(theme::BG_ALT).fg(theme::FG) + }; + let mut spans: Vec = Vec::new(); + + // status icon + let is_this = sync_device.as_ref().map(|d| d == &device.name).unwrap_or(false); + if is_this { + spans.push(Span::styled( + app.spinner().to_string(), + Style::default().fg(theme::BG), + )); + } else if device.mounted { + spans.push(Span::styled("●", Style::default().fg(if selected { theme::BG } else { theme::GREEN }))); + } else { + spans.push(Span::styled("○", Style::default().fg(theme::DIM))); + } + spans.push(Span::raw(" ")); + + // selection marker + name + let marker = if selected { "▸ " } else { " " }; + let name = truncate(&device.name, row.width.max(1) as usize - 8); + spans.push(Span::styled( + format!("{marker}{name}"), + Style::default().add_modifier(if selected { Modifier::BOLD } else { Modifier::default() }), + )); + + f.render_widget(Paragraph::new(Line::from(spans)).style(row_style), *row); + + // second line + let sub = if let Some(last) = device.last_sync { + format!( + " {} · {} · {}", + device.firmware.label(), + device.content_type, + relative_ago(last) + ) + } else { + format!(" {} · {}", device.firmware.label(), device.content_type) + }; + let sub_style = if selected { + Style::default().bg(theme::ACCENT).fg(theme::BG) + } else { + Style::default().bg(theme::BG_ALT).fg(theme::DIM) + }; + let row2 = Rect { + y: row.y + 1, + ..*row + }; + f.render_widget( + Paragraph::new(Line::from(Span::styled(truncate(&sub, row.width.max(1) as usize), sub_style))) + .style(sub_style), + row2, + ); + } +} + +// ── tab bar ─────────────────────────────────────────────────────────────────── + +pub fn draw_tabbar(f: &mut Frame, area: Rect, app: &App) { + let mut spans: Vec = Vec::new(); + for (i, tab) in Tab::ALL.iter().enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + } + let active = app.tab == *tab; + let syncing_badge = *tab == Tab::Sync && app.is_syncing(); + let text = format!(" {} {} ", tab.number(), tab.label()); + if active { + spans.push(Span::styled( + text, + Style::default() + .bg(theme::ACCENT) + .fg(theme::BG) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + text, + Style::default().bg(theme::BG_ALT).fg(theme::DIM), + )); + } + if syncing_badge { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + app.spinner().to_string(), + Style::default().fg(theme::CYAN), + )); + } + } + let rest: u16 = area + .width + .saturating_sub(spans.iter().map(|s| s.width() as u16).sum()); + if rest > 0 { + spans.push(Span::raw(" ".repeat(rest as usize))); + } + f.render_widget( + Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG)), + area, + ); +} + +// ── status bar ──────────────────────────────────────────────────────────────── + +pub fn draw_status(f: &mut Frame, area: Rect, app: &App) { + let (msg, style) = status_text(app); + let left_spans: Vec = vec![ + Span::styled(" ", Style::default()), + Span::styled(msg, style), + ]; + + let mounted = app.config.devices.iter().filter(|d| d.mounted).count(); + let now = chrono::Local::now().format("%H:%M:%S").to_string(); + let right_spans = vec![ + Span::styled( + format!("{} devices · {mounted} mounted", app.config.devices.len()), + Style::default().fg(theme::DIM), + ), + Span::raw(" "), + Span::styled( + format!("v{} · {now}", crate::app::VERSION), + Style::default().fg(theme::DIM), + ), + ]; + + let total = area.width as usize; + let lw: usize = left_spans.iter().map(|s| s.width()).sum(); + let rw: usize = right_spans.iter().map(|s| s.width()).sum(); + let pad = total.saturating_sub(lw + rw); + let mut all = left_spans; + all.push(Span::raw(" ".repeat(pad))); + all.extend(right_spans); + + f.render_widget( + Paragraph::new(Line::from(all)).style(Style::default().bg(theme::BG_ALT)), + area, + ); +} + +fn status_text(app: &App) -> (String, Style) { + if let Some((msg, level)) = &app.status_msg { + let style = match level { + LogLevel::Error => Style::default().fg(theme::RED).add_modifier(Modifier::BOLD), + LogLevel::Warn => Style::default().fg(theme::YELLOW), + _ => Style::default().fg(theme::GREEN), + }; + return (format!(" {msg}"), style); + } + if app.is_syncing() { + let dev = app + .engine + .state + .lock() + .unwrap() + .device + .clone() + .unwrap_or_default(); + return ( + format!(" {} Syncing {dev}…", app.spinner()), + Style::default().fg(theme::CYAN), + ); + } + (" Ready".to_string(), Style::default().fg(theme::DIM)) +} + +// ── key bar ─────────────────────────────────────────────────────────────────── + +pub fn draw_keys(f: &mut Frame, area: Rect, _app: &App) { + let keys: &[(&str, &str)] = &[ + ("↑↓/jk", "nav"), + ("s", "sync"), + ("a", "abort"), + ("Tab/1-4", "tab"), + ("r", "refresh"), + ("f", "follow"), + ("?", "help"), + ("q", "quit"), + ]; + let mut spans: Vec = Vec::new(); + let mut first = true; + for (key, label) in keys { + if !first { + spans.push(Span::styled(" ", Style::default().fg(theme::DIM))); + } + first = false; + spans.push(Span::styled( + format!(" {key} "), + Style::default().fg(theme::ACCENT).bg(theme::BG_ALT).add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled(*label, Style::default().fg(theme::DIM))); + } + let rest = area.width.saturating_sub(spans.iter().map(|s| s.width() as u16).sum()); + if rest > 0 { + spans.push(Span::raw(" ".repeat(rest as usize))); + } + f.render_widget( + Paragraph::new(Line::from(spans)) + .style(Style::default().bg(theme::BG)) + .alignment(Alignment::Left), + area, + ); +} + +// ── shared helpers ──────────────────────────────────────────────────────────── + +pub fn step_icon(status: StepStatus, app: &App) -> Span<'static> { + match status { + StepStatus::Done => Span::styled("●", Style::default().fg(theme::GREEN)), + StepStatus::Active => Span::styled( + app.spinner().to_string(), + Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD), + ), + StepStatus::Pending => Span::styled("○", Style::default().fg(theme::DIM)), + StepStatus::Error => Span::styled("✗", Style::default().fg(theme::RED).add_modifier(Modifier::BOLD)), + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..623819a --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,108 @@ +pub mod components; +pub mod tabs; +pub mod theme; + +use crate::app::App; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::widgets::{Block, Borders, Clear}; + +use theme::{BG, DIM}; + +pub fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let mut out: String = s.chars().take(max.saturating_sub(1)).collect(); + out.push('…'); + out + } +} + +pub fn draw(f: &mut Frame, app: &App) { + let area = f.area(); + f.render_widget(Block::default().style(Style::default().bg(BG)), area); + + let v = Layout::vertical([ + Constraint::Length(2), // title + Constraint::Length(1), // gradient rule + Constraint::Min(0), // body + Constraint::Length(1), // status + Constraint::Length(1), // keys + ]); + let [title, rule, body, status, keys] = v.areas(area); + + components::draw_title(f, title, app); + components::draw_rule(f, rule, app); + + let [side, main] = Layout::horizontal([Constraint::Length(34), Constraint::Min(0)]).areas(body); + components::draw_sidebar(f, side, app); + draw_main(f, main, app); + + components::draw_status(f, status, app); + components::draw_keys(f, keys, app); + + if app.show_help { + draw_help(f); + } +} + +fn draw_main(f: &mut Frame, area: Rect, app: &App) { + let [tabbar, content] = + Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area); + components::draw_tabbar(f, tabbar, app); + match app.tab { + crate::app::Tab::Dashboard => tabs::dashboard::draw(f, content, app), + crate::app::Tab::Sync => tabs::sync::draw(f, content, app), + crate::app::Tab::Logs => tabs::logs::draw(f, content, app), + crate::app::Tab::Config => tabs::config_view::draw(f, content, app), + } +} + +fn draw_help(f: &mut Frame) { + let area = f.area(); + let box_w = 58; + let box_h = 16; + let x = (area.width.saturating_sub(box_w)) / 2; + let y = (area.height.saturating_sub(box_h)) / 2; + let rect = Rect::new( + x.max(0), + y.max(0), + box_w.min(area.width), + box_h.min(area.height), + ); + f.render_widget(Clear, rect); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(crate::ui::theme::ACCENT)) + .style(Style::default().bg(crate::ui::theme::PANEL_BG)) + .title(" KEYBINDINGS ".cyan().bold()); + let lines = [ + " ↑↓ / j k move selection", + " s / Enter sync selected device", + " a abort running sync", + " Tab / 1-4 switch tab", + " r refresh devices", + " f toggle log follow", + " u / d scroll logs", + " x clear logs", + " ? / h this help", + " q / Esc quit", + "", + " ← → (todo: device nav)", + ]; + let mut spans = Vec::new(); + for (i, line) in lines.iter().enumerate() { + if i > 0 { + spans.push(ratatui::text::Line::raw("")); + } + let styled = if line.contains('/') { + ratatui::text::Span::styled(*line, ratatui::style::Style::default().fg(crate::ui::theme::FG)) + } else { + ratatui::text::Span::styled(*line, ratatui::style::Style::default().fg(DIM)) + }; + spans.push(ratatui::text::Line::from(styled)); + } + f.render_widget(ratatui::widgets::Paragraph::new(ratatui::text::Text::from(spans)).block(block), rect); +} diff --git a/src/ui/tabs/config_view.rs b/src/ui/tabs/config_view.rs new file mode 100644 index 0000000..7e3bc3b --- /dev/null +++ b/src/ui/tabs/config_view.rs @@ -0,0 +1,84 @@ +use crate::app::App; +use crate::ui::theme; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, BorderType, Paragraph}; + +pub fn draw(f: &mut Frame, area: Rect, app: &App) { + let [head, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area); + + let path = app.config.config_file.display().to_string(); + let head_line = Line::from(vec![ + Span::styled(" config", Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::styled(path.clone(), Style::default().fg(theme::DIM)), + ]); + f.render_widget( + Paragraph::new(head_line).style(Style::default().bg(theme::BG)), + head, + ); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme::DIM)) + .style(Style::default().bg(theme::PANEL_BG)); + let inner = block.inner(body); + f.render_widget(block, body); + + let lines: Vec = app + .config + .raw_text + .lines() + .map(syntax_line) + .collect(); + + let content_h = inner.height.saturating_sub(1) as usize; + let offset = lines.len().saturating_sub(content_h); + + f.render_widget( + Paragraph::new(lines) + .scroll((offset as u16, 0)) + .style(Style::default().bg(theme::PANEL_BG)), + inner, + ); +} + +fn syntax_line(raw: &str) -> Line<'static> { + let line = raw; + let trimmed = line.trim_start(); + + if trimmed.is_empty() { + return Line::raw(""); + } + if trimmed.starts_with('#') { + return Line::from(Span::styled(line.to_string(), Style::default().fg(theme::DIM))); + } + if trimmed.starts_with('[') { + return Line::from(Span::styled( + line.to_string(), + Style::default().fg(theme::MAGENTA).add_modifier(Modifier::BOLD), + )); + } + if let Some(idx) = line.find('=') { + let key = line[..idx].trim_end(); + let value = line[idx + 1..].trim_start(); + let value_style = if value.starts_with('"') { + Style::default().fg(theme::GREEN) + } else if value.starts_with('[') { + Style::default().fg(theme::CYAN) + } else if value.starts_with("true") || value.starts_with("false") { + Style::default().fg(theme::YELLOW) + } else { + Style::default().fg(theme::ORANGE) + }; + return Line::from(vec![ + Span::styled(format!("{key}"), Style::default().fg(theme::ACCENT)), + Span::styled(" = ", Style::default().fg(theme::DIM)), + Span::styled(value.to_string(), value_style), + ]); + } + Line::from(Span::styled(line.to_string(), Style::default().fg(theme::FG))) +} diff --git a/src/ui/tabs/dashboard.rs b/src/ui/tabs/dashboard.rs new file mode 100644 index 0000000..e916dfa --- /dev/null +++ b/src/ui/tabs/dashboard.rs @@ -0,0 +1,213 @@ +use crate::app::{relative_ago, App}; +use crate::ui::theme; +use crate::ui::truncate; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, BorderType, Paragraph}; + +fn card(title: &str) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme::DIM)) + .style(Style::default().bg(theme::PANEL_BG)) + .title(Span::styled( + format!(" {title} "), + Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD), + )) +} + +fn kv<'a>(label: &str, value: String, value_style: Style) -> Line<'a> { + Line::from(vec![ + Span::styled( + format!(" {label:<12}"), + Style::default().fg(theme::DIM), + ), + Span::styled(value, value_style), + ]) +} + +fn firmware_badge(fw: &str) -> Span<'static> { + let color = match fw { + "rockbox" => theme::MAGENTA, + "android" => theme::CYAN, + "mlove" => theme::ORANGE, + _ => theme::GREEN, + }; + Span::styled( + format!(" [{fw}] "), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ) +} + +pub fn draw(f: &mut Frame, area: Rect, app: &App) { + let Some(device) = app.selected_device() else { + draw_empty(f, area); + return; + }; + + let [top, chain] = Layout::vertical([Constraint::Min(8), Constraint::Length(4)]).areas(area); + let [left, right] = + Layout::horizontal([Constraint::Percentage(55), Constraint::Percentage(45)]).areas(top); + + // ── device card ── + let dev_block = card("DEVICE"); + let inner = dev_block.inner(left); + f.render_widget(dev_block, left); + + let status = if device.mounted { + Span::styled("● mounted", Style::default().fg(theme::GREEN)) + } else { + Span::styled("○ not mounted", Style::default().fg(theme::RED)) + }; + let fw = device.firmware.label().to_string(); + let last = match device.last_sync { + Some(dt) => { + if device.last_status == "ok" { + format!("{} (ok)", relative_ago(dt)) + } else { + format!("{} (error)", relative_ago(dt)) + } + } + None => "never".to_string(), + }; + + let lines = vec![ + Line::from(vec![ + Span::raw(" "), + Span::styled( + truncate(&device.name, inner.width.saturating_sub(4) as usize), + Style::default().fg(theme::FG).add_modifier(Modifier::BOLD), + ), + firmware_badge(&fw), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled(" status", Style::default().fg(theme::DIM)), + Span::raw(" "), + status.clone(), + ]), + kv("firmware", fw.clone(), Style::default().fg(theme::FG)), + kv("content", device.content_type.clone(), Style::default().fg(theme::FG)), + kv("mount", device.mount_str(), Style::default().fg(theme::CYAN)), + kv("folder", device.music_folder.clone(), Style::default().fg(theme::CYAN)), + kv("last sync", last, Style::default().fg(theme::YELLOW)), + ]; + f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); + + // ── library card ── + let lib_block = card("LIBRARY"); + let inner = lib_block.inner(right); + f.render_widget(lib_block, right); + + let excludes = app.config.storagebox_excludes.len(); + let lib_lines = vec![ + kv( + "music", + app.config.local_music_dir.display().to_string(), + Style::default().fg(theme::CYAN), + ), + kv( + "hoerspiele", + app.config.local_hoerspiele_dir.display().to_string(), + Style::default().fg(theme::CYAN), + ), + Line::raw(""), + kv( + "storagebox", + app.config.storagebox_source.clone(), + Style::default().fg(theme::FG), + ), + kv( + "source hsp", + app.config.storagebox_hoerspiele_source.clone(), + Style::default().fg(theme::FG), + ), + kv( + "excludes", + format!("{excludes} patterns"), + Style::default().fg(theme::DIM), + ), + Line::raw(""), + kv( + "output", + app.config.output_dir.display().to_string(), + Style::default().fg(theme::DIM), + ), + ]; + f.render_widget(Paragraph::new(lib_lines).style(Style::default().bg(theme::PANEL_BG)), inner); + + // ── source chain ── + draw_chain(f, chain, app, device.name.clone()); +} + +fn draw_chain(f: &mut Frame, area: Rect, app: &App, device_name: String) { + let block = card("SYNC PATH"); + let inner = block.inner(area); + f.render_widget(block, area); + + let w = inner.width.saturating_sub(2) as usize; + // reserve: indent(2) + 2×arrow(20) + 3×chip brackets(9) + let avail = w.saturating_sub(31); + let seg1 = avail * 40 / 100; + let seg2 = avail * 30 / 100; + let seg3 = avail.saturating_sub(seg1 + seg2); + + let src = truncate(&app.config.storagebox_source, seg1.max(3)); + let local = truncate(&app.config.local_music_dir.display().to_string(), seg2.max(3)); + let device = truncate(&device_name, seg3.max(3)); + + let mut line = vec![ + Span::raw(" "), + chip(&src), + arrow("rclone"), + chip(&local), + arrow("rsync"), + chip(&device), + ]; + let used: usize = line.iter().map(|s| s.width()).sum(); + if used < inner.width as usize { + line.push(Span::raw(" ".repeat(inner.width as usize - used))); + } + f.render_widget( + Paragraph::new(Line::from(line)).style(Style::default().bg(theme::PANEL_BG)), + inner, + ); +} + +fn chip(text: &str) -> Span<'static> { + Span::styled( + format!(" [{text}]"), + Style::default().fg(theme::BG).bg(theme::ACCENT).add_modifier(Modifier::BOLD), + ) +} + +fn arrow(label: &str) -> Span<'static> { + Span::styled( + format!(" ─{label}▶ "), + Style::default().fg(theme::CYAN), + ) +} + +fn draw_empty(f: &mut Frame, area: Rect) { + let block = card("DEVICE"); + let inner = block.inner(area); + f.render_widget(block, area); + f.render_widget( + Paragraph::new(vec![ + Line::raw(""), + Line::from(Span::styled( + " No devices configured.", + Style::default().fg(theme::YELLOW), + )), + Line::from(Span::styled( + " Add [[devices]] entries to your config.toml", + Style::default().fg(theme::DIM), + )), + ]) + .style(Style::default().bg(theme::PANEL_BG)), + inner, + ); +} diff --git a/src/ui/tabs/logs.rs b/src/ui/tabs/logs.rs new file mode 100644 index 0000000..bc68607 --- /dev/null +++ b/src/ui/tabs/logs.rs @@ -0,0 +1,91 @@ +use crate::app::App; +use crate::engine::LogLevel; +use crate::ui::theme; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, BorderType, Paragraph}; + +fn level_style(level: LogLevel) -> Style { + match level { + LogLevel::Info => Style::default().fg(theme::FG), + LogLevel::Transfer => Style::default().fg(theme::CYAN), + LogLevel::Warn => Style::default().fg(theme::YELLOW), + LogLevel::Error => Style::default().fg(theme::RED).add_modifier(Modifier::BOLD), + LogLevel::Success => Style::default().fg(theme::GREEN), + } +} + +fn level_tag(level: LogLevel) -> &'static str { + match level { + LogLevel::Info => "INF", + LogLevel::Transfer => "XFER", + LogLevel::Warn => "WRN", + LogLevel::Error => "ERR", + LogLevel::Success => "OK", + } +} + +pub fn draw(f: &mut Frame, area: Rect, app: &App) { + let [head, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area); + + let follow_tag = if app.log_follow { + "follow on".to_string() + } else { + "follow off".to_string() + }; + let head_line = Line::from(vec![ + Span::styled( + format!(" {} log entries", app.logs.len()), + Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD), + ), + Span::raw(" · "), + Span::styled(follow_tag, Style::default().fg(theme::DIM)), + Span::raw(" · "), + Span::styled( + "f toggle · u/d scroll · x clear", + Style::default().fg(theme::DIM), + ), + ]); + f.render_widget( + Paragraph::new(head_line).style(Style::default().bg(theme::BG)), + head, + ); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme::DIM)) + .style(Style::default().bg(theme::PANEL_BG)); + let inner = block.inner(body); + f.render_widget(block, body); + + let lines: Vec = app + .logs + .iter() + .map(|e| { + let ts = e.ts.format("%H:%M:%S").to_string(); + Line::from(vec![ + Span::styled(format!(" {ts} "), Style::default().fg(theme::DIM)), + Span::styled(format!("{:>4}", level_tag(e.level)), level_style(e.level)), + Span::raw(" "), + Span::styled(e.msg.clone(), level_style(e.level)), + ]) + }) + .collect(); + + let content_h = inner.height.saturating_sub(1) as usize; + let offset = if app.log_follow { + lines.len().saturating_sub(content_h) + } else { + app.log_scroll.min(lines.len().saturating_sub(content_h)) + }; + + f.render_widget( + Paragraph::new(lines) + .scroll((offset as u16, 0)) + .style(Style::default().bg(theme::PANEL_BG)), + inner, + ); +} diff --git a/src/ui/tabs/mod.rs b/src/ui/tabs/mod.rs new file mode 100644 index 0000000..ff49686 --- /dev/null +++ b/src/ui/tabs/mod.rs @@ -0,0 +1,4 @@ +pub mod config_view; +pub mod dashboard; +pub mod logs; +pub mod sync; diff --git a/src/ui/tabs/sync.rs b/src/ui/tabs/sync.rs new file mode 100644 index 0000000..6b7793d --- /dev/null +++ b/src/ui/tabs/sync.rs @@ -0,0 +1,246 @@ +use crate::app::App; +use crate::engine::{StepState, StepStatus, SyncState}; +use crate::ui::components::step_icon; +use crate::ui::theme; +use crate::ui::truncate; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, BorderType, Gauge, Paragraph}; + +fn panel(title: &str) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme::DIM)) + .style(Style::default().bg(theme::PANEL_BG)) + .title(Span::styled( + format!(" {title} "), + Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD), + )) +} + +pub fn draw(f: &mut Frame, area: Rect, app: &App) { + let state = app.engine.state.lock().unwrap().clone(); + + let header = match (&state.device, state.running) { + (Some(name), true) => { + if state.aborting { + format!(" Aborting sync → {name}") + } else { + format!(" Syncing → {name}") + } + } + (Some(name), false) => format!(" Ready to sync → {name}"), + _ => " Sync".to_string(), + }; + let [head, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + header, + Style::default() + .fg(theme::FG) + .add_modifier(Modifier::BOLD), + ))) + .style(Style::default().bg(theme::BG)), + head, + ); + + if !state.running { + draw_idle(f, body, &state, app); + return; + } + + let [steps, progress] = + Layout::vertical([Constraint::Min(6), Constraint::Length(6)]).areas(body); + draw_steps(f, steps, &state, app); + draw_progress(f, progress, &state); +} + +fn draw_idle(f: &mut Frame, area: Rect, state: &SyncState, _app: &App) { + let [info, hint] = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).areas(area); + + let block = panel("LAST SYNC"); + let inner = block.inner(info); + f.render_widget(block, info); + + let mut lines: Vec = Vec::new(); + if let Some(ok) = state.result { + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + if ok { "✓" } else { "✗" }, + Style::default().fg(if ok { theme::GREEN } else { theme::RED }).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(state.summary.clone(), Style::default().fg(theme::FG)), + ])); + } else { + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + "No sync has been run yet.", + Style::default().fg(theme::DIM), + ), + ])); + } + if let Some(name) = &state.device { + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("Press ", Style::default().fg(theme::DIM)), + Span::styled(" s ", Style::default().fg(theme::BG).bg(theme::ACCENT).add_modifier(Modifier::BOLD)), + Span::styled(format!(" to sync {name}"), Style::default().fg(theme::DIM)), + ])); + } + f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); + + let hint_block = panel("PIPELINE"); + let hint_inner = hint_block.inner(hint); + f.render_widget(hint_block, hint); + f.render_widget( + Paragraph::new(vec![ + Line::from(vec![ + Span::raw(" "), + Span::styled("1 ", Style::default().fg(theme::ACCENT)), + Span::styled("rclone sync", Style::default().fg(theme::FG)), + Span::styled(" → ", Style::default().fg(theme::DIM)), + Span::styled("2 ", Style::default().fg(theme::ACCENT)), + Span::styled("NFC diff", Style::default().fg(theme::FG)), + Span::styled(" → ", Style::default().fg(theme::DIM)), + Span::styled("3 ", Style::default().fg(theme::ACCENT)), + Span::styled("rsync", Style::default().fg(theme::FG)), + Span::styled(" → ", Style::default().fg(theme::DIM)), + Span::styled("4 ", Style::default().fg(theme::ACCENT)), + Span::styled("eject / fatsort", Style::default().fg(theme::FG)), + ]), + ]) + .style(Style::default().bg(theme::PANEL_BG)), + hint_inner, + ); +} + +fn draw_steps(f: &mut Frame, area: Rect, state: &SyncState, app: &App) { + let block = panel("STEPS"); + let inner = block.inner(area); + f.render_widget(block, area); + + let mut lines: Vec = Vec::new(); + lines.push(Line::raw("")); + for step in &state.steps { + lines.push(step_line(step, app)); + lines.push(Line::raw("")); + } + f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); +} + +fn step_line(step: &StepState, app: &App) -> Line<'static> { + let icon = step_icon(step.status, app); + let title_style = match step.status { + StepStatus::Active => Style::default().fg(theme::FG).add_modifier(Modifier::BOLD), + StepStatus::Done => Style::default().fg(theme::GREEN), + StepStatus::Error => Style::default().fg(theme::RED), + StepStatus::Pending => Style::default().fg(theme::DIM), + }; + Line::from(vec![ + Span::raw(" "), + icon, + Span::raw(" "), + Span::styled(step.title.clone(), title_style), + Span::raw(" "), + Span::styled(step.detail.clone(), Style::default().fg(theme::DIM)), + ]) +} + +fn draw_progress(f: &mut Frame, area: Rect, state: &SyncState) { + let block = panel("PROGRESS"); + let inner = block.inner(area); + f.render_widget(block, area); + + let active = state + .steps + .iter() + .position(|s| s.status == StepStatus::Active) + .unwrap_or(usize::MAX); + + let (pct, done, total, speed, eta, current, label) = if active == 0 { + ( + state.mirror.pct, + state.mirror.done.clone(), + state.mirror.total.clone(), + state.mirror.speed.clone(), + state.mirror.eta.clone(), + state.mirror.current.clone(), + "Mirroring local library", + ) + } else if active == 1 { + ( + state.device_prog.pct, + state.device_prog.done.clone(), + state.device_prog.total.clone(), + state.device_prog.speed.clone(), + state.device_prog.eta.clone(), + state.device_prog.current.clone(), + "Syncing device", + ) + } else { + (0, String::new(), String::new(), String::new(), String::new(), String::new(), "Finalizing") + }; + + let [gauge_row, stats] = Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(inner); + + let g = Gauge::default() + .block(Block::default().borders(Borders::BOTTOM).border_style(Style::default().fg(theme::DIM))) + .gauge_style(Style::default().fg(theme::ACCENT).bg(theme::BG_ALT)) + .ratio((pct as f64) / 100.0) + .label(Span::styled( + format!(" {label} · {pct}% "), + Style::default().fg(theme::FG).add_modifier(Modifier::BOLD), + )); + f.render_widget(g, gauge_row); + + let mut lines: Vec = Vec::new(); + if !done.is_empty() || !total.is_empty() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + if done.is_empty() { "…".to_string() } else { done.clone() }, + Style::default().fg(theme::CYAN), + ), + Span::styled(" / ", Style::default().fg(theme::DIM)), + Span::styled( + if total.is_empty() { "…".to_string() } else { total }, + Style::default().fg(theme::CYAN), + ), + ])); + } + let mut right: Vec = Vec::new(); + if !speed.is_empty() { + right.push(Span::styled(speed, Style::default().fg(theme::YELLOW))); + } + if !eta.is_empty() { + right.push(Span::styled(" ETA ", Style::default().fg(theme::DIM))); + right.push(Span::styled(eta, Style::default().fg(theme::YELLOW))); + } + if !current.is_empty() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + truncate(¤t, inner.width.saturating_sub(2) as usize), + Style::default().fg(theme::DIM), + ), + ])); + } else { + lines.push(Line::raw("")); + } + let mut stats_line = lines; + stats_line.push(Line::from( + std::iter::once(Span::raw(" ")) + .chain(right.into_iter()) + .collect::>(), + )); + f.render_widget(Paragraph::new(stats_line).style(Style::default().bg(theme::PANEL_BG)), stats); +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..23a2e8a --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,15 @@ +use ratatui::style::Color; + +// Tokyo Night palette +pub const BG: Color = Color::Rgb(0x1a, 0x1b, 0x26); +pub const BG_ALT: Color = Color::Rgb(0x16, 0x16, 0x1e); +pub const PANEL_BG: Color = Color::Rgb(0x1f, 0x23, 0x33); +pub const FG: Color = Color::Rgb(0xc0, 0xca, 0xf5); +pub const DIM: Color = Color::Rgb(0x56, 0x5f, 0x89); +pub const ACCENT: Color = Color::Rgb(0x7a, 0xa2, 0xf7); +pub const CYAN: Color = Color::Rgb(0x7d, 0xcf, 0xff); +pub const GREEN: Color = Color::Rgb(0x9e, 0xce, 0x6a); +pub const YELLOW: Color = Color::Rgb(0xe0, 0xaf, 0x68); +pub const RED: Color = Color::Rgb(0xf7, 0x76, 0x8e); +pub const MAGENTA: Color = Color::Rgb(0xbb, 0x9a, 0xf7); +pub const ORANGE: Color = Color::Rgb(0xff, 0x9e, 0x64);