From a5cfe04bcf77b060eb6c1d5331f6e6fe7ede935f Mon Sep 17 00:00:00 2001 From: Michael O'Brien Date: Sat, 21 Feb 2026 00:11:28 -0500 Subject: [PATCH 01/13] add TUI impl --- Cargo.lock | 709 ++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 3 + src/browse/app.rs | 313 +++++++++++++++++++ src/browse/events.rs | 245 +++++++++++++++ src/browse/mod.rs | 119 ++++++++ src/browse/ui.rs | 358 ++++++++++++++++++++++ src/main.rs | 47 ++- src/put/files.rs | 37 ++- 8 files changed, 1809 insertions(+), 22 deletions(-) create mode 100644 src/browse/app.rs create mode 100644 src/browse/events.rs create mode 100644 src/browse/mod.rs create mode 100644 src/browse/ui.rs diff --git a/Cargo.lock b/Cargo.lock index 115815f..6b34fd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -17,6 +17,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.3" @@ -26,6 +32,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -90,6 +102,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.52.0", + "x11rb", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -112,7 +144,7 @@ dependencies = [ "cc", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.7.4", "object", "rustc-demangle", ] @@ -168,12 +200,39 @@ dependencies = [ "regex", ] +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[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.1.6" @@ -239,12 +298,34 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "confy" version = "0.6.1" @@ -273,6 +354,46 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -360,6 +481,22 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encoding_rs" version = "0.8.34" @@ -377,26 +514,77 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", "windows-sys 0.52.0", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "fastrand" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide 0.8.9", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -481,6 +669,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.3", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -517,6 +715,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -529,6 +738,17 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "heck" version = "0.4.1" @@ -705,6 +925,20 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -739,6 +973,24 @@ version = "1.70.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" @@ -758,11 +1010,14 @@ dependencies = [ name = "kaput-cli" version = "2.5.0" dependencies = [ + "arboard", "base64", "blake2", "bytefmt", "clap", "confy", + "crossterm", + "ratatui", "reqwest", "serde", "serde_json", @@ -772,9 +1027,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.155" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libredox" @@ -792,12 +1047,36 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[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.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.7.4" @@ -829,6 +1108,28 @@ dependencies = [ "adler", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.1" @@ -841,6 +1142,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "native-tls" version = "0.2.12" @@ -873,6 +1184,79 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.36.2" @@ -959,6 +1343,35 @@ dependencies = [ "unicode-width", ] +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1003,6 +1416,19 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1042,15 +1468,59 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" -version = "1.0.36" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.4.5" @@ -1166,7 +1636,20 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.14", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", "windows-sys 0.52.0", ] @@ -1210,6 +1693,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.18" @@ -1225,6 +1714,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "2.11.1" @@ -1331,6 +1826,43 @@ dependencies = [ "syn 2.0.87", ] +[[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 0.8.11", + "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 = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.9" @@ -1362,12 +1894,50 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn 2.0.87", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.87", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1457,7 +2027,7 @@ checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ "cfg-if", "fastrand", - "rustix", + "rustix 0.38.34", "windows-sys 0.52.0", ] @@ -1481,6 +2051,20 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.36" @@ -1536,7 +2120,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 1.0.1", "pin-project-lite", "socket2", "windows-sys 0.52.0", @@ -1698,6 +2282,23 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.1.11" @@ -1830,6 +2431,34 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +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.52.0" @@ -1839,6 +2468,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-registry" version = "0.2.0" @@ -2017,8 +2652,60 @@ dependencies = [ "memchr", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.3", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index a6d0c8e..fccfeb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,9 @@ serde_json = { version = "1.0.132", features = ["std"] } serde_with = { version = "3.11.0", features = [] } base64 = "0.22.1" blake2 = "0.10.6" +ratatui = "0.26" +crossterm = "0.27" +arboard = "3" [[bin]] name = "kaput" diff --git a/src/browse/app.rs b/src/browse/app.rs new file mode 100644 index 0000000..1494c5e --- /dev/null +++ b/src/browse/app.rs @@ -0,0 +1,313 @@ +use crate::put::files::File; + +#[derive(Clone, Copy, PartialEq)] +pub enum SortField { + Name, + Size, + Date, +} + +#[derive(Clone, Copy, PartialEq)] +pub enum SortDirection { + Asc, + Desc, +} + +pub struct BreadcrumbEntry { + pub id: i64, + pub name: String, + pub saved_index: usize, + pub saved_offset: usize, +} + +pub enum AppState { + Browsing, + Quitting, +} + +pub enum ModalState { + None, + Loading, + ConfirmDelete { file_id: i64, file_name: String }, + FileActions { file_id: i64, file_name: String, file_type: String, selected: usize }, + Find { query: String }, + SearchInput { query: String }, + Error(String), + Success(String), +} + +/// Returns the ordered list of actions available for a given file type. +/// Used by both the event handler and the UI renderer. +pub fn file_actions_for(file_type: &str, in_search_results: bool) -> Vec<&'static str> { + let mut actions = if file_type == "VIDEO" { + vec!["Copy URL", "Copy Stream URL", "Download", "Open in browser", "Copy file ID"] + } else { + vec!["Copy URL", "Download", "Open in browser", "Copy file ID"] + }; + if in_search_results { + actions.push("Go to folder"); + } + actions +} + +pub enum PendingAction { + None, + Download { file_id: i64 }, + Search { query: String }, + GoToFolder { parent_id: i64, file_id: i64 }, +} + +pub struct BrowserApp { + pub current_folder_id: i64, + pub breadcrumbs: Vec, + pub files: Vec, + pub selected_index: usize, + pub app_state: AppState, + pub modal: ModalState, + pub pending_action: PendingAction, + pub sort_field: SortField, + pub sort_direction: SortDirection, + pub list_state: ratatui::widgets::ListState, + restore_index: Option, + restore_offset: Option, + pub tick: u8, + pub needs_reload: bool, + pub last_search: Option, + pub is_search_results: bool, + pub pending_select_id: Option, +} + +impl BrowserApp { + pub fn new() -> Self { + let mut list_state = ratatui::widgets::ListState::default(); + list_state.select(Some(0)); + BrowserApp { + current_folder_id: 0, + breadcrumbs: vec![BreadcrumbEntry { id: 0, name: "My Files".to_string(), saved_index: 0, saved_offset: 0 }], + files: vec![], + selected_index: 0, + app_state: AppState::Browsing, + modal: ModalState::Loading, + pending_action: PendingAction::None, + sort_field: SortField::Name, + sort_direction: SortDirection::Asc, + list_state, + restore_index: None, + restore_offset: None, + tick: 0, + needs_reload: true, + last_search: None, + is_search_results: false, + pending_select_id: None, + } + } + + pub fn enter_folder(&mut self, id: i64, name: String) { + // Save cursor and scroll position so we can restore them when going back + if let Some(current) = self.breadcrumbs.last_mut() { + current.saved_index = self.selected_index; + current.saved_offset = *self.list_state.offset_mut(); + } + self.breadcrumbs.push(BreadcrumbEntry { id, name, saved_index: 0, saved_offset: 0 }); + self.current_folder_id = id; + self.files.clear(); + self.selected_index = 0; + self.list_state.select(Some(0)); + self.modal = ModalState::Loading; + } + + pub fn go_back(&mut self) { + if self.breadcrumbs.len() > 1 { + self.breadcrumbs.pop(); + self.is_search_results = false; + let parent = self.breadcrumbs.last().unwrap(); + self.current_folder_id = parent.id; + self.restore_index = Some(parent.saved_index); + self.restore_offset = Some(parent.saved_offset); + self.files.clear(); + self.selected_index = 0; + self.list_state.select(Some(0)); + self.modal = ModalState::Loading; + } + } + + pub fn set_files(&mut self, files: Vec) { + self.files = files; + self.sort_files(); + let (idx, apply_scroll) = if let Some(select_id) = self.pending_select_id.take() { + let i = self.files.iter().position(|f| f.id == select_id).unwrap_or(0); + (i, false) // let ratatui auto-scroll to the selected item + } else { + let i = self.restore_index.take() + .unwrap_or(0) + .min(self.files.len().saturating_sub(1)); + (i, true) + }; + self.selected_index = idx; + self.list_state.select(Some(idx)); + if apply_scroll { + if let Some(offset) = self.restore_offset.take() { + *self.list_state.offset_mut() = offset.min(self.files.len().saturating_sub(1)); + } + } else { + self.restore_offset = None; + } + self.modal = ModalState::None; + } + + /// Display search results. Pushes a virtual breadcrumb (id = -1). + /// If already showing search results, replaces them in-place. + pub fn enter_search_results(&mut self, query: &str, files: Vec) { + if self.is_search_results { + // Replace current search results without stacking breadcrumbs + if let Some(crumb) = self.breadcrumbs.last_mut() { + crumb.name = format!("Search: {}", query); + } + } else { + // Save cursor position so going back restores it + if let Some(current) = self.breadcrumbs.last_mut() { + current.saved_index = self.selected_index; + current.saved_offset = *self.list_state.offset_mut(); + } + self.breadcrumbs.push(BreadcrumbEntry { + id: -1, + name: format!("Search: {}", query), + saved_index: 0, + saved_offset: 0, + }); + self.is_search_results = true; + } + self.files = files; + self.selected_index = 0; + self.list_state.select(Some(0)); + *self.list_state.offset_mut() = 0; + self.modal = ModalState::None; + } + + /// Reset navigation back to root, clearing any search context. + pub fn reset_to_root(&mut self) { + self.breadcrumbs.truncate(1); + self.breadcrumbs[0].saved_index = 0; + self.breadcrumbs[0].saved_offset = 0; + self.is_search_results = false; + self.current_folder_id = 0; + } + + /// Navigate directly to a folder and pre-select a file by id. + pub fn navigate_to_folder(&mut self, parent_id: i64, file_id: i64) { + self.reset_to_root(); + if parent_id != 0 { + self.breadcrumbs.push(BreadcrumbEntry { + id: parent_id, + name: "...".to_string(), // updated from API response after load + saved_index: 0, + saved_offset: 0, + }); + self.current_folder_id = parent_id; + } + self.pending_select_id = Some(file_id); + self.files.clear(); + self.selected_index = 0; + self.list_state.select(Some(0)); + self.modal = ModalState::Loading; + } + + fn sort_files(&mut self) { + let field = self.sort_field; + let dir = self.sort_direction; + self.files.sort_by(|a, b| { + let ord = match field { + SortField::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + SortField::Size => a.size.0.cmp(&b.size.0), + SortField::Date => a.created_at.cmp(&b.created_at), + }; + if dir == SortDirection::Desc { ord.reverse() } else { ord } + }); + } + + pub fn cycle_sort_field(&mut self) { + self.sort_field = match self.sort_field { + SortField::Name => SortField::Size, + SortField::Size => SortField::Date, + SortField::Date => SortField::Name, + }; + self.sort_files(); + self.selected_index = 0; + self.list_state.select(Some(0)); + } + + pub fn toggle_sort_direction(&mut self) { + self.sort_direction = match self.sort_direction { + SortDirection::Asc => SortDirection::Desc, + SortDirection::Desc => SortDirection::Asc, + }; + self.sort_files(); + self.selected_index = 0; + self.list_state.select(Some(0)); + } + + pub fn selected_file(&self) -> Option<&File> { + self.files.get(self.selected_index) + } + + /// Preserve the current cursor and scroll position across the next reload. + pub fn save_position_for_reload(&mut self) { + self.restore_index = Some(self.selected_index); + self.restore_offset = Some(*self.list_state.offset_mut()); + } + + pub fn move_up(&mut self) { + if self.selected_index > 0 { + self.selected_index -= 1; + self.list_state.select(Some(self.selected_index)); + } + } + + pub fn move_down(&mut self) { + if !self.files.is_empty() && self.selected_index < self.files.len() - 1 { + self.selected_index += 1; + self.list_state.select(Some(self.selected_index)); + } + } + + /// Jump to the next file matching `query`, starting after the current selection. + /// Wraps around. Returns true if a match was found. + pub fn find_next_with(&mut self, query: &str) -> bool { + if query.is_empty() || self.files.is_empty() { + return false; + } + let q = query.to_lowercase(); + let n = self.files.len(); + for offset in 1..=n { + let i = (self.selected_index + offset) % n; + if self.files[i].name.to_lowercase().contains(&q) { + self.selected_index = i; + self.list_state.select(Some(i)); + return true; + } + } + false + } + + /// Repeat the last search. + pub fn find_next(&mut self) -> bool { + if let Some(query) = self.last_search.clone() { + self.find_next_with(&query) + } else { + false + } + } + + pub fn move_page_up(&mut self) { + self.selected_index = self.selected_index.saturating_sub(10); + self.list_state.select(Some(self.selected_index)); + } + + pub fn move_page_down(&mut self) { + if !self.files.is_empty() { + let last = self.files.len() - 1; + self.selected_index = (self.selected_index + 10).min(last); + self.list_state.select(Some(self.selected_index)); + } + } +} diff --git a/src/browse/events.rs b/src/browse/events.rs new file mode 100644 index 0000000..b5c1381 --- /dev/null +++ b/src/browse/events.rs @@ -0,0 +1,245 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use reqwest::blocking::Client; + +use super::app::{file_actions_for, AppState, BrowserApp, ModalState, PendingAction}; +use crate::put; + +pub fn handle_key(app: &mut BrowserApp, key: KeyEvent, client: &Client, api_token: &String) { + if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c') { + app.app_state = AppState::Quitting; + return; + } + + match &app.modal { + ModalState::Loading => {} + + ModalState::Error(_) | ModalState::Success(_) => { + app.modal = ModalState::None; + } + + ModalState::ConfirmDelete { file_id, .. } => { + let file_id = *file_id; + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + match put::files::delete(client, api_token, &file_id.to_string()) { + Ok(_) => { + app.save_position_for_reload(); + app.modal = ModalState::Loading; + app.needs_reload = true; + } + Err(e) => { + app.modal = ModalState::Error(format!("Delete failed: {}", e)); + } + } + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + app.modal = ModalState::None; + } + _ => {} + } + } + + ModalState::FileActions { file_id, file_name, file_type, selected } => { + // Extract owned copies so the borrow on app.modal ends. + let file_id = *file_id; + let selected = *selected; + let file_name = file_name.clone(); + let file_type = file_type.clone(); + let in_search = app.is_search_results; + let n = file_actions_for(&file_type, in_search).len(); + + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + let new = if selected == 0 { n - 1 } else { selected - 1 }; + app.modal = ModalState::FileActions { file_id, file_name, file_type, selected: new }; + } + KeyCode::Down | KeyCode::Char('j') => { + app.modal = ModalState::FileActions { + file_id, + file_name, + file_type: file_type.clone(), + selected: (selected + 1) % n, + }; + } + KeyCode::Enter => { + let action = file_actions_for(&file_type, in_search)[selected]; + app.modal = ModalState::None; + execute_file_action(app, action, file_id, &file_type, api_token, client); + } + KeyCode::Esc | KeyCode::Char('q') => { + app.modal = ModalState::None; + } + _ => {} + } + } + + ModalState::SearchInput { query } => { + let query = query.clone(); + match key.code { + KeyCode::Esc => { + app.modal = ModalState::None; + } + KeyCode::Enter => { + if !query.is_empty() { + app.pending_action = PendingAction::Search { query }; + app.modal = ModalState::Loading; + } else { + app.modal = ModalState::None; + } + } + KeyCode::Backspace => { + let mut q = query; + q.pop(); + app.modal = ModalState::SearchInput { query: q }; + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + app.modal = ModalState::SearchInput { query: query + &c.to_string() }; + } + _ => {} + } + } + + ModalState::Find { query } => { + let query = query.clone(); + match key.code { + KeyCode::Esc => { + app.modal = ModalState::None; + } + KeyCode::Enter => { + app.modal = ModalState::None; + if !query.is_empty() { + app.last_search = Some(query.clone()); + app.find_next_with(&query); + } + } + KeyCode::Backspace => { + let mut q = query; + q.pop(); + app.modal = ModalState::Find { query: q }; + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + app.modal = ModalState::Find { query: query + &c.to_string() }; + } + _ => {} + } + } + + ModalState::None => match key.code { + KeyCode::Char('q') => { + app.app_state = AppState::Quitting; + } + KeyCode::Esc => { + if app.breadcrumbs.len() > 1 { + app.go_back(); + app.needs_reload = true; + } else { + app.app_state = AppState::Quitting; + } + } + KeyCode::Up | KeyCode::Char('k') => app.move_up(), + KeyCode::Down | KeyCode::Char('j') => app.move_down(), + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => app.move_page_up(), + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => app.move_page_down(), + KeyCode::Enter => { + if let Some(file) = app.selected_file() { + let file_id = file.id; + let file_name = file.name.clone(); + let file_type = file.file_type.clone(); + if file_type == "FOLDER" { + app.enter_folder(file_id, file_name); + app.needs_reload = true; + } else { + app.modal = ModalState::FileActions { + file_id, + file_name, + file_type, + selected: 0, + }; + } + } + } + KeyCode::Left | KeyCode::Backspace => { + app.go_back(); + app.needs_reload = true; + } + KeyCode::Char('/') => { + app.modal = ModalState::Find { query: String::new() }; + } + KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.modal = ModalState::SearchInput { query: String::new() }; + } + KeyCode::Char('n') => { + app.find_next(); + } + KeyCode::Char('s') => app.cycle_sort_field(), + KeyCode::Char('r') => app.toggle_sort_direction(), + KeyCode::Char('x') => { + if let Some(file) = app.selected_file() { + let file_id = file.id; + let file_name = file.name.clone(); + app.modal = ModalState::ConfirmDelete { file_id, file_name }; + } + } + _ => {} + }, + } +} + +fn execute_file_action( + app: &mut BrowserApp, + action: &str, + file_id: i64, + file_type: &str, + api_token: &String, + client: &Client, +) { + match action { + "Copy URL" => match put::files::url(client, api_token, file_id) { + Ok(r) => copy_to_clipboard(app, &r.url, "URL copied!"), + Err(e) => app.modal = ModalState::Error(format!("Failed to get URL: {}", e)), + }, + "Copy Stream URL" => { + let url = format!( + "https://api.put.io/v2/files/{}/stream?oauth_token={}", + file_id, api_token + ); + copy_to_clipboard(app, &url, "Stream URL copied!"); + } + "Download" => { + app.pending_action = PendingAction::Download { file_id }; + } + "Open in browser" => { + open_in_browser(app, &format!("https://app.put.io/files/{}", file_id)); + } + "Copy file ID" => { + copy_to_clipboard(app, &file_id.to_string(), "File ID copied!"); + } + "Go to folder" => { + let parent_id = app.files.iter() + .find(|f| f.id == file_id) + .map(|f| f.parent_id) + .unwrap_or(0); + app.pending_action = PendingAction::GoToFolder { parent_id, file_id }; + } + _ => {} + } + let _ = file_type; +} + +fn open_in_browser(app: &mut BrowserApp, url: &str) { + let cmd = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; + match std::process::Command::new(cmd).arg(url).spawn() { + Ok(_) => app.modal = ModalState::Success("Opening in browser...".to_string()), + Err(e) => app.modal = ModalState::Error(format!("Could not open browser: {}", e)), + } +} + +fn copy_to_clipboard(app: &mut BrowserApp, text: &str, success_msg: &str) { + match arboard::Clipboard::new() { + Ok(mut cb) => match cb.set_text(text) { + Ok(_) => app.modal = ModalState::Success(success_msg.to_string()), + Err(e) => app.modal = ModalState::Error(format!("Clipboard error: {}", e)), + }, + Err(e) => app.modal = ModalState::Error(format!("Clipboard unavailable: {}", e)), + } +} diff --git a/src/browse/mod.rs b/src/browse/mod.rs new file mode 100644 index 0000000..51a08c5 --- /dev/null +++ b/src/browse/mod.rs @@ -0,0 +1,119 @@ +mod app; +mod events; +mod ui; + +use std::io; + +use crossterm::{ + event::{self, Event}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use reqwest::blocking::Client; + +use app::{AppState, BrowserApp, ModalState, PendingAction}; +use crate::put; + +pub fn run(client: &Client, api_token: &String) -> io::Result<()> { + // Restore terminal on panic + std::panic::set_hook(Box::new(|info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + eprintln!("{info}"); + })); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = BrowserApp::new(); + + loop { + app.tick = app.tick.wrapping_add(1); + terminal.draw(|f| ui::draw(f, &mut app))?; + + if matches!(app.app_state, AppState::Quitting) { + break; + } + + // Draw happens before the load so the spinner is visible during the request. + if app.needs_reload { + app.needs_reload = false; + load_current_folder(&mut app, client, api_token); + continue; + } + + // Handle pending actions that need the spinner frame to render first. + let pending = std::mem::replace(&mut app.pending_action, PendingAction::None); + match pending { + PendingAction::None => {} + + PendingAction::Search { query } => { + match put::files::search(client, api_token, &query) { + Ok(r) => app.enter_search_results(&query, r.files), + Err(e) => app.modal = ModalState::Error(format!("Search failed: {}", e)), + } + } + + PendingAction::GoToFolder { parent_id, file_id } => { + app.navigate_to_folder(parent_id, file_id); + app.needs_reload = true; + } + + PendingAction::Download { file_id } => { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + match put::files::download(client, api_token, file_id, false, None, false) { + Ok(_) => {} + Err(e) => eprintln!("Download error: {}", e), + } + + println!("\nPress Enter to return to the file browser..."); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + + enable_raw_mode()?; + execute!(terminal.backend_mut(), EnterAlternateScreen)?; + terminal.clear()?; + } + } + + if matches!(app.app_state, AppState::Quitting) { + break; + } + + if event::poll(std::time::Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + events::handle_key(&mut app, key, client, api_token); + } + } + } + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + Ok(()) +} + +pub(super) fn load_current_folder(app: &mut BrowserApp, client: &Client, api_token: &String) { + match put::files::list(client, api_token, app.current_folder_id) { + Ok(r) => { + // Update the breadcrumb name from the API response (covers "Go to folder" placeholders) + if app.current_folder_id != 0 { + if let Some(crumb) = app.breadcrumbs.last_mut() { + if crumb.id == app.current_folder_id { + crumb.name = r.parent.name.clone(); + } + } + } + app.set_files(r.files); + } + Err(e) => app.modal = ModalState::Error(e.to_string()), + } +} diff --git a/src/browse/ui.rs b/src/browse/ui.rs new file mode 100644 index 0000000..d3298ac --- /dev/null +++ b/src/browse/ui.rs @@ -0,0 +1,358 @@ +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Clear, List, ListItem, Padding, Paragraph}, +}; + +use super::app::{file_actions_for, AppState, BrowserApp, ModalState}; + +const MODAL_BG: Color = Color::Rgb(45, 45, 58); + +pub fn draw(f: &mut Frame, app: &mut BrowserApp) { + if matches!(app.app_state, AppState::Quitting) { + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // breadcrumb + Constraint::Min(0), // file list + Constraint::Length(2), // help bar + ]) + .split(f.size()); + + draw_breadcrumb(f, app, chunks[0]); + draw_file_list(f, app, chunks[1]); + draw_help_bar(f, chunks[2]); + + // Draw modal overlays last + match &app.modal { + ModalState::Loading => draw_spinner(f, app.tick), + ModalState::Error(msg) => draw_error_modal(f, msg.clone()), + ModalState::Success(msg) => draw_success_modal(f, msg.clone()), + ModalState::ConfirmDelete { file_name, .. } => draw_confirm_modal(f, file_name.clone()), + ModalState::FileActions { file_name, file_type, selected, .. } => { + draw_file_actions_modal(f, file_name, file_type, *selected, app.is_search_results); + } + ModalState::Find { query } => draw_find_bar(f, query), + ModalState::SearchInput { query } => draw_search_input(f, query), + ModalState::None => {} + } +} + +fn draw_breadcrumb(f: &mut Frame, app: &BrowserApp, area: Rect) { + let crumb_style = Style::default().fg(Color::White).add_modifier(Modifier::BOLD); + let sep_style = Style::default().fg(Color::DarkGray); + + let mut spans: Vec = vec![Span::raw(" ")]; + for (i, entry) in app.breadcrumbs.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" › ", sep_style)); + } + spans.push(Span::styled(truncate(&entry.name, 24), crumb_style)); + } + + f.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn draw_file_list(f: &mut Frame, app: &mut BrowserApp, area: Rect) { + let search = app.last_search.clone(); + let items: Vec = app + .files + .iter() + .enumerate() + .map(|(i, file)| { + let cursor = if i == app.selected_index { ">>" } else { " " }; + let icon = file_type_icon(&file.file_type); + let color = file_type_color(&file.file_type); + let is_folder = file.file_type == "FOLDER"; + let name_style = if is_folder { + Style::default().fg(color).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(color) + }; + let size_str = if is_folder { + "—".to_string() + } else { + file.size.to_string() + }; + let name_trunc = truncate(&file.name, 55); + let padding = " ".repeat(55usize.saturating_sub(name_trunc.chars().count()) + 1); + + let mut spans = vec![ + Span::raw(format!("{} ", cursor)), + Span::styled(format!("{} ", icon), name_style), + ]; + if let Some(ref query) = search { + let match_style = name_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + spans.extend(highlight_match(&name_trunc, query, name_style, match_style)); + } else { + spans.push(Span::styled(name_trunc, name_style)); + } + spans.push(Span::styled(padding, name_style)); + spans.push(Span::styled(format!("{:>10}", size_str), Style::default().fg(Color::DarkGray))); + + ListItem::new(Line::from(spans)) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().borders(Borders::NONE)) + .highlight_style( + Style::default() + .bg(Color::LightCyan) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + ); + + f.render_stateful_widget(list, area, &mut app.list_state); +} + +fn draw_help_bar(f: &mut Frame, area: Rect) { + let k = Style::default().fg(Color::White).add_modifier(Modifier::BOLD); + let l = Style::default().fg(Color::DarkGray); + let sep = Span::styled(" ", l); + + // 4 columns, 2 rows. Within each column: key right-aligned, label left-aligned. + // Col 1: (key_w=5, label_w=8) Navigate / Back + // Col 2: (key_w=5, label_w=6) Open / Delete + // Col 3: (key_w=1, label_w=7) Sort / Reverse + // Col 4: (key_w=5, label_w=6) ^U/^D Scroll / q Quit + let row1 = Line::from(vec![ + Span::styled("↑↓", k), Span::styled("/", l), Span::styled("jk", k), Span::styled(format!(" {:<8}", "Navigate"), l), sep.clone(), + Span::styled(format!("{:>5}", "Enter"), k), Span::styled(format!(" {:<6}", "Open"), l), sep.clone(), + Span::styled(format!("{:>1}", "s"), k), Span::styled(format!(" {:<7}", "Sort"), l), sep.clone(), + Span::styled("^U", k), Span::styled("/", l), Span::styled("^D", k), Span::styled(format!(" {:<6}", "Scroll"), l), + ]); + let row2 = Line::from(vec![ + Span::styled(format!("{:>5}", "Bksp"), k), Span::styled(format!(" {:<8}", "Back"), l), sep.clone(), + Span::styled(format!("{:>5}", "x"), k), Span::styled(format!(" {:<6}", "Delete"), l), sep.clone(), + Span::styled(format!("{:>1}", "r"), k), Span::styled(format!(" {:<7}", "Reverse"), l), sep.clone(), + Span::styled(format!("{:>5}", "q"), k), Span::styled(format!(" {:<6}", "Quit"), l), + ]); + + f.render_widget( + Paragraph::new(vec![row1, row2]).alignment(Alignment::Center), + area, + ); +} + +fn centered_rect(percent_x: u16, height: u16, r: Rect) -> Rect { + let popup_width = r.width * percent_x / 100; + let x = r.x + (r.width.saturating_sub(popup_width)) / 2; + let y = r.y + (r.height.saturating_sub(height)) / 2; + Rect { + x, + y, + width: popup_width.min(r.width), + height: height.min(r.height), + } +} + +fn draw_search_input(f: &mut Frame, query: &str) { + let area = centered_rect(50, 5, f.size()); + f.render_widget(Clear, area); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .padding(Padding::symmetric(2, 1)) + .title(" Search put.io ") + .style(Style::default().fg(Color::Cyan).bg(MODAL_BG)); + let inner = block.inner(area); + f.render_widget(block, area); + f.render_widget( + Paragraph::new(query).style(Style::default().fg(Color::White).bg(MODAL_BG)), + inner, + ); + let cursor_x = (inner.x + query.chars().count() as u16).min(inner.x + inner.width.saturating_sub(1)); + f.set_cursor(cursor_x, inner.y); +} + +fn draw_find_bar(f: &mut Frame, query: &str) { + let size = f.size(); + let y = size.height.saturating_sub(1); + let area = Rect { x: 0, y, width: size.width, height: 1 }; + f.render_widget(Clear, area); + let line = Line::from(vec![ + Span::styled("/", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Span::styled(query, Style::default().fg(Color::White)), + ]); + f.render_widget(Paragraph::new(line), area); + // Place the real terminal cursor at the end of the query + let cursor_x = (1 + query.chars().count() as u16).min(size.width.saturating_sub(1)); + f.set_cursor(cursor_x, y); +} + +fn draw_spinner(f: &mut Frame, tick: u8) { + const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let ch = FRAMES[tick as usize % FRAMES.len()]; + let size = f.size(); + let area = Rect { + x: 1, + y: size.height.saturating_sub(1), + width: 1, + height: 1, + }; + f.render_widget( + Paragraph::new(ch.to_string()).style(Style::default().fg(Color::Yellow)), + area, + ); +} + +fn draw_error_modal(f: &mut Frame, msg: String) { + let area = centered_rect(50, 7, f.size()); + f.render_widget(Clear, area); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .padding(Padding::symmetric(2, 1)) + .title(" Error ") + .style(Style::default().fg(Color::Red).bg(MODAL_BG)); + let inner = block.inner(area); + f.render_widget(block, area); + let p = Paragraph::new(format!("{}\n\nPress any key to dismiss", msg)) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Red).bg(MODAL_BG)); + f.render_widget(p, inner); +} + +fn draw_success_modal(f: &mut Frame, msg: String) { + let area = centered_rect(40, 5, f.size()); + f.render_widget(Clear, area); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .padding(Padding::symmetric(2, 1)) + .title(" Done ") + .style(Style::default().fg(Color::Black).bg(Color::Green)); + let inner = block.inner(area); + f.render_widget(block, area); + let p = Paragraph::new(msg.as_str()) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Black).bg(Color::Green)); + f.render_widget(p, inner); +} + +fn draw_file_actions_modal(f: &mut Frame, file_name: &str, file_type: &str, selected: usize, in_search_results: bool) { + let actions = file_actions_for(file_type, in_search_results); + let height = actions.len() as u16 + 4; // borders + vertical padding + let area = centered_rect(38, height, f.size()); + f.render_widget(Clear, area); + + let title = format!(" {} ", truncate(file_name, 28)); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .padding(Padding::symmetric(1, 1)) + .title(title) + .style(Style::default().bg(MODAL_BG)); + let inner = block.inner(area); + f.render_widget(block, area); + + let items: Vec = actions + .iter() + .enumerate() + .map(|(i, action)| { + let is_sel = i == selected; + let cursor = if is_sel { "▶" } else { " " }; + let style = if is_sel { + Style::default() + .bg(Color::LightCyan) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default().bg(MODAL_BG) + }; + ListItem::new(Line::from(vec![ + Span::styled(format!(" {} ", cursor), style), + Span::styled(action.to_string(), style), + // Fill the rest of the row so the highlight spans the full width + Span::styled(" ".repeat(30), style), + ])) + }) + .collect(); + + f.render_widget(List::new(items), inner); +} + +fn draw_confirm_modal(f: &mut Frame, file_name: String) { + let area = centered_rect(50, 7, f.size()); + f.render_widget(Clear, area); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .padding(Padding::symmetric(2, 1)) + .title(" Confirm Delete ") + .style(Style::default().fg(Color::Yellow).bg(MODAL_BG)); + let inner = block.inner(area); + f.render_widget(block, area); + let p = Paragraph::new(format!("Delete \"{}\"?\n\n[y] Yes [n] No", file_name)) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Yellow).bg(MODAL_BG)); + f.render_widget(p, inner); +} + +fn file_type_icon(file_type: &str) -> &'static str { + match file_type { + "FOLDER" => "\u{f07b}", // + "VIDEO" => "\u{f03d}", // + "AUDIO" => "\u{f001}", // + "IMAGE" => "\u{f03e}", // + "ARCHIVE" => "\u{f1c6}", // + "PDF" => "\u{f1c1}", // + "TEXT" => "\u{f15c}", // + _ => "\u{f15b}", // + } +} + +fn file_type_color(file_type: &str) -> Color { + match file_type { + // Folders: bright warm yellow — visually dominant + "FOLDER" => Color::LightYellow, + // Files: standard (non-bright) colors, clearly subordinate to folders + "VIDEO" => Color::Green, + "AUDIO" => Color::Magenta, + "IMAGE" => Color::Cyan, + "ARCHIVE" => Color::Red, + "PDF" => Color::Red, + _ => Color::Gray, + } +} + +/// Splits `name` into up to three spans: text before the match, the matched +/// substring (styled with `highlight`), and text after. Falls back to a single +/// span with `base` style if no match is found. +fn highlight_match(name: &str, query: &str, base: Style, highlight: Style) -> Vec> { + if !query.is_empty() { + let lower_name = name.to_lowercase(); + let lower_query = query.to_lowercase(); + if let Some(start) = lower_name.find(lower_query.as_str()) { + let end = start + lower_query.len(); + if name.is_char_boundary(start) && name.is_char_boundary(end) { + let mut spans = Vec::new(); + if start > 0 { + spans.push(Span::styled(name[..start].to_string(), base)); + } + spans.push(Span::styled(name[start..end].to_string(), highlight)); + if end < name.len() { + spans.push(Span::styled(name[end..].to_string(), base)); + } + return spans; + } + } + } + vec![Span::styled(name.to_string(), base)] +} + +fn truncate(s: &str, max_chars: usize) -> String { + let count = s.chars().count(); + if count <= max_chars { + s.to_string() + } else { + let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect(); + format!("{}…", truncated) + } +} diff --git a/src/main.rs b/src/main.rs index c662614..e9e87ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use clap::{value_parser, Arg, Command}; +use clap::{value_parser, Arg, ArgGroup, Command}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -6,6 +6,7 @@ use std::process::{Command as ProcessCommand, Stdio}; use std::{thread, time}; use tabled::{settings::Style, Table}; +mod browse; mod put; #[derive(Debug, Serialize, Deserialize)] @@ -114,11 +115,23 @@ fn cli() -> Command { .about("Download a file or folder") .long_about("Downloads a file or folder from your account to your device.") .arg_required_else_help(true) + .group( + ArgGroup::new("target") + .args(["SOURCE_PATH", "id"]) + .required(true) + ) .arg( - Arg::new("FILE_ID") + Arg::new("SOURCE_PATH") + .help("Path to a file or folder on Put.io (e.g. Movies/film.mkv)") + .required(false) + ) + .arg( + Arg::new("id") + .short('i') + .long("id") .value_parser(value_parser!(i64)) - .required(true) - .help("ID(s) of a file or folder (required)") + .help("ID of a file or folder on Put.io") + .required(false) ) .arg( Arg::new("path") @@ -322,6 +335,10 @@ fn cli() -> Command { ) ) ) + .subcommand( + Command::new("browse") + .about("Browse your files interactively") + ) .subcommand( Command::new("whoami") .about("Check what account you are logged into") @@ -519,17 +536,23 @@ fn main() { let recursive = sub_matches.get_flag("recursive"); let no_replace = sub_matches.get_flag("no-replace"); - let path = sub_matches.get_one::("path"); - let file_id = sub_matches - .get_one("FILE_ID") - .expect("missing file_id argument"); + let dest_path = sub_matches.get_one::("path"); + let file_id = if let Some(id) = sub_matches.get_one::("id") { + *id + } else { + let source_path = sub_matches + .get_one::("SOURCE_PATH") + .expect("missing source path"); + put::files::resolve_path(&client, &config.api_token, source_path) + .unwrap_or_else(|e| panic!("Could not find '{}': {}", source_path, e)) + }; put::files::download( &client, &config.api_token, - *file_id, + file_id, recursive, - path, + dest_path, no_replace, ) .expect("downloading file(s)"); @@ -713,6 +736,10 @@ fn main() { } }, + Some(("browse", _)) => { + require_auth(&client, &config); + browse::run(&client, &config.api_token).expect("error running file browser"); + } _ => { println!("Invalid command. Try using the `--help` flag.") } diff --git a/src/put/files.rs b/src/put/files.rs index af80d80..b2cd5ab 100644 --- a/src/put/files.rs +++ b/src/put/files.rs @@ -12,7 +12,7 @@ use tabled::Tabled; use crate::put; #[derive(Debug, Serialize, Deserialize)] -pub struct FileSize(u64); +pub struct FileSize(pub u64); impl fmt::Display for FileSize { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { @@ -38,6 +38,41 @@ pub struct FilesResponse { pub parent: File, } +/// Resolves a slash-separated path (e.g. "Movies/Action/film.mkv") to a file ID +/// by walking the Put.io folder tree from the root. Matching is case-insensitive. +/// Returns an error string if any component is not found. +pub fn resolve_path(client: &Client, api_token: &String, path: &str) -> Result { + let parts: Vec<&str> = path + .trim_matches('/') + .split('/') + .filter(|s| !s.is_empty()) + .collect(); + + let mut current_id: i64 = 0; + + for (i, part) in parts.iter().enumerate() { + let response = list(client, api_token, current_id) + .map_err(|e| e.to_string())?; + + let found = response + .files + .iter() + .find(|f| f.name.to_lowercase() == part.to_lowercase()); + + match found { + Some(file) => { + if i < parts.len() - 1 && file.file_type != "FOLDER" { + return Err(format!("'{}' is not a folder", part)); + } + current_id = file.id; + } + None => return Err(format!("'{}' not found", part)), + } + } + + Ok(current_id) +} + /// Returns the user's files. pub fn list(client: &Client, api_token: &String, parent_id: i64) -> Result { let response: FilesResponse = client From 189196a79d96afb7f204a794a4ca9f252eb973e5 Mon Sep 17 00:00:00 2001 From: Michael O'Brien Date: Sat, 21 Feb 2026 22:41:53 -0500 Subject: [PATCH 02/13] support file paths, download as zip --- src/browse/app.rs | 40 +++++++++++++++++++++++++++++++------ src/browse/events.rs | 46 +++++++++++++++++++++++++++++++++++++++---- src/browse/ui.rs | 47 ++++++++++++++++++++++++++------------------ src/put/files.rs | 5 ++++- 4 files changed, 108 insertions(+), 30 deletions(-) diff --git a/src/browse/app.rs b/src/browse/app.rs index 1494c5e..4045ac9 100644 --- a/src/browse/app.rs +++ b/src/browse/app.rs @@ -5,6 +5,7 @@ pub enum SortField { Name, Size, Date, + Modified, } #[derive(Clone, Copy, PartialEq)] @@ -36,16 +37,41 @@ pub enum ModalState { Success(String), } +pub struct FileAction { + pub label: &'static str, + pub key: char, +} + /// Returns the ordered list of actions available for a given file type. /// Used by both the event handler and the UI renderer. -pub fn file_actions_for(file_type: &str, in_search_results: bool) -> Vec<&'static str> { - let mut actions = if file_type == "VIDEO" { - vec!["Copy URL", "Copy Stream URL", "Download", "Open in browser", "Copy file ID"] +pub fn file_actions_for(file_type: &str, in_search_results: bool) -> Vec { + let mut actions = if file_type == "FOLDER" { + vec![ + FileAction { label: "Download as zip", key: 'z' }, + FileAction { label: "Open in browser", key: 'b' }, + FileAction { label: "Copy path", key: 'p' }, + FileAction { label: "Copy folder ID", key: 'i' }, + ] + } else if file_type == "VIDEO" { + vec![ + FileAction { label: "Copy URL", key: 'c' }, + FileAction { label: "Copy Stream URL", key: 's' }, + FileAction { label: "Download", key: 'd' }, + FileAction { label: "Open in browser", key: 'b' }, + FileAction { label: "Copy path", key: 'p' }, + FileAction { label: "Copy file ID", key: 'i' }, + ] } else { - vec!["Copy URL", "Download", "Open in browser", "Copy file ID"] + vec![ + FileAction { label: "Copy URL", key: 'c' }, + FileAction { label: "Download", key: 'd' }, + FileAction { label: "Open in browser", key: 'b' }, + FileAction { label: "Copy path", key: 'p' }, + FileAction { label: "Copy file ID", key: 'i' }, + ] }; if in_search_results { - actions.push("Go to folder"); + actions.push(FileAction { label: "Go to folder", key: 'g' }); } actions } @@ -220,6 +246,7 @@ impl BrowserApp { SortField::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), SortField::Size => a.size.0.cmp(&b.size.0), SortField::Date => a.created_at.cmp(&b.created_at), + SortField::Modified => a.updated_at.cmp(&b.updated_at), }; if dir == SortDirection::Desc { ord.reverse() } else { ord } }); @@ -229,7 +256,8 @@ impl BrowserApp { self.sort_field = match self.sort_field { SortField::Name => SortField::Size, SortField::Size => SortField::Date, - SortField::Date => SortField::Name, + SortField::Date => SortField::Modified, + SortField::Modified => SortField::Name, }; self.sort_files(); self.selected_index = 0; diff --git a/src/browse/events.rs b/src/browse/events.rs index b5c1381..e0c8180 100644 --- a/src/browse/events.rs +++ b/src/browse/events.rs @@ -46,7 +46,8 @@ pub fn handle_key(app: &mut BrowserApp, key: KeyEvent, client: &Client, api_toke let file_name = file_name.clone(); let file_type = file_type.clone(); let in_search = app.is_search_results; - let n = file_actions_for(&file_type, in_search).len(); + let actions = file_actions_for(&file_type, in_search); + let n = actions.len(); match key.code { KeyCode::Up | KeyCode::Char('k') => { @@ -62,11 +63,18 @@ pub fn handle_key(app: &mut BrowserApp, key: KeyEvent, client: &Client, api_toke }; } KeyCode::Enter => { - let action = file_actions_for(&file_type, in_search)[selected]; + let label = actions[selected].label; app.modal = ModalState::None; - execute_file_action(app, action, file_id, &file_type, api_token, client); + execute_file_action(app, label, file_id, &file_type, api_token, client); } - KeyCode::Esc | KeyCode::Char('q') => { + KeyCode::Char(c) => { + if let Some(action) = actions.iter().find(|a| a.key == c) { + let label = action.label; + app.modal = ModalState::None; + execute_file_action(app, label, file_id, &file_type, api_token, client); + } + } + KeyCode::Esc => { app.modal = ModalState::None; } _ => {} @@ -140,6 +148,16 @@ pub fn handle_key(app: &mut BrowserApp, key: KeyEvent, client: &Client, api_toke KeyCode::Down | KeyCode::Char('j') => app.move_down(), KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => app.move_page_up(), KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => app.move_page_down(), + KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(file) = app.selected_file() { + app.modal = ModalState::FileActions { + file_id: file.id, + file_name: file.name.clone(), + file_type: file.file_type.clone(), + selected: 0, + }; + } + } KeyCode::Enter => { if let Some(file) = app.selected_file() { let file_id = file.id; @@ -214,6 +232,26 @@ fn execute_file_action( "Copy file ID" => { copy_to_clipboard(app, &file_id.to_string(), "File ID copied!"); } + "Copy path" => { + let file_name = app.files.iter() + .find(|f| f.id == file_id) + .map(|f| f.name.clone()) + .unwrap_or_default(); + let mut parts: Vec = app.breadcrumbs + .iter() + .skip(1) // skip root "My Files" + .map(|b| b.name.clone()) + .collect(); + parts.push(file_name); + let path = parts.join("/"); + copy_to_clipboard(app, &path, "Path copied!"); + } + "Download as zip" => { + app.pending_action = PendingAction::Download { file_id }; + } + "Copy folder ID" => { + copy_to_clipboard(app, &file_id.to_string(), "Folder ID copied!"); + } "Go to folder" => { let parent_id = app.files.iter() .find(|f| f.id == file_id) diff --git a/src/browse/ui.rs b/src/browse/ui.rs index d3298ac..1a02612 100644 --- a/src/browse/ui.rs +++ b/src/browse/ui.rs @@ -6,7 +6,7 @@ use ratatui::{ widgets::{Block, BorderType, Borders, Clear, List, ListItem, Padding, Paragraph}, }; -use super::app::{file_actions_for, AppState, BrowserApp, ModalState}; +use super::app::{file_actions_for, AppState, BrowserApp, FileAction, ModalState, SortField}; const MODAL_BG: Color = Color::Rgb(45, 45, 58); @@ -26,7 +26,7 @@ pub fn draw(f: &mut Frame, app: &mut BrowserApp) { draw_breadcrumb(f, app, chunks[0]); draw_file_list(f, app, chunks[1]); - draw_help_bar(f, chunks[2]); + draw_help_bar(f, app, chunks[2]); // Draw modal overlays last match &app.modal { @@ -111,26 +111,33 @@ fn draw_file_list(f: &mut Frame, app: &mut BrowserApp, area: Rect) { f.render_stateful_widget(list, area, &mut app.list_state); } -fn draw_help_bar(f: &mut Frame, area: Rect) { +fn draw_help_bar(f: &mut Frame, app: &BrowserApp, area: Rect) { let k = Style::default().fg(Color::White).add_modifier(Modifier::BOLD); let l = Style::default().fg(Color::DarkGray); let sep = Span::styled(" ", l); + let sort_label = match app.sort_field { + SortField::Name => "Name ", + SortField::Size => "Size ", + SortField::Date => "Date ", + SortField::Modified => "Modified", + }; + // 4 columns, 2 rows. Within each column: key right-aligned, label left-aligned. // Col 1: (key_w=5, label_w=8) Navigate / Back // Col 2: (key_w=5, label_w=6) Open / Delete - // Col 3: (key_w=1, label_w=7) Sort / Reverse + // Col 3: (key_w=1, label_w=8) Sort(field) / Reverse // Col 4: (key_w=5, label_w=6) ^U/^D Scroll / q Quit let row1 = Line::from(vec![ Span::styled("↑↓", k), Span::styled("/", l), Span::styled("jk", k), Span::styled(format!(" {:<8}", "Navigate"), l), sep.clone(), - Span::styled(format!("{:>5}", "Enter"), k), Span::styled(format!(" {:<6}", "Open"), l), sep.clone(), - Span::styled(format!("{:>1}", "s"), k), Span::styled(format!(" {:<7}", "Sort"), l), sep.clone(), + Span::styled(format!("{:>5}", "Enter"), k), Span::styled("/", l), Span::styled("^O", k), Span::styled(format!(" {:<6}", "Open"), l), sep.clone(), + Span::styled(format!("{:>1}", "s"), k), Span::styled(format!(" {:<8}", sort_label), l), sep.clone(), Span::styled("^U", k), Span::styled("/", l), Span::styled("^D", k), Span::styled(format!(" {:<6}", "Scroll"), l), ]); let row2 = Line::from(vec![ Span::styled(format!("{:>5}", "Bksp"), k), Span::styled(format!(" {:<8}", "Back"), l), sep.clone(), Span::styled(format!("{:>5}", "x"), k), Span::styled(format!(" {:<6}", "Delete"), l), sep.clone(), - Span::styled(format!("{:>1}", "r"), k), Span::styled(format!(" {:<7}", "Reverse"), l), sep.clone(), + Span::styled(format!("{:>1}", "r"), k), Span::styled(format!(" {:<8}", "Reverse"), l), sep.clone(), Span::styled(format!("{:>5}", "q"), k), Span::styled(format!(" {:<6}", "Quit"), l), ]); @@ -227,12 +234,12 @@ fn draw_success_modal(f: &mut Frame, msg: String) { .border_type(BorderType::Rounded) .padding(Padding::symmetric(2, 1)) .title(" Done ") - .style(Style::default().fg(Color::Black).bg(Color::Green)); + .style(Style::default().fg(Color::Green).bg(MODAL_BG)); let inner = block.inner(area); f.render_widget(block, area); let p = Paragraph::new(msg.as_str()) .alignment(Alignment::Center) - .style(Style::default().fg(Color::Black).bg(Color::Green)); + .style(Style::default().fg(Color::Green).bg(MODAL_BG)); f.render_widget(p, inner); } @@ -255,22 +262,24 @@ fn draw_file_actions_modal(f: &mut Frame, file_name: &str, file_type: &str, sele let items: Vec = actions .iter() .enumerate() - .map(|(i, action)| { + .map(|(i, FileAction { label, key })| { let is_sel = i == selected; let cursor = if is_sel { "▶" } else { " " }; - let style = if is_sel { - Style::default() - .bg(Color::LightCyan) - .fg(Color::Black) - .add_modifier(Modifier::BOLD) + let (row_style, key_style) = if is_sel { + let s = Style::default().bg(Color::LightCyan).fg(Color::Black).add_modifier(Modifier::BOLD); + (s, s) } else { - Style::default().bg(MODAL_BG) + ( + Style::default().bg(MODAL_BG), + Style::default().bg(MODAL_BG).fg(Color::DarkGray), + ) }; ListItem::new(Line::from(vec![ - Span::styled(format!(" {} ", cursor), style), - Span::styled(action.to_string(), style), + Span::styled(format!(" {} ", cursor), row_style), + Span::styled(format!("[{}] ", key), key_style), + Span::styled(label.to_string(), row_style), // Fill the rest of the row so the highlight spans the full width - Span::styled(" ".repeat(30), style), + Span::styled(" ".repeat(30), row_style), ])) }) .collect(); diff --git a/src/put/files.rs b/src/put/files.rs index b2cd5ab..4cda3fe 100644 --- a/src/put/files.rs +++ b/src/put/files.rs @@ -28,6 +28,9 @@ pub struct File { pub file_type: String, pub size: FileSize, pub created_at: String, + #[serde(default)] + #[tabled(skip)] + pub updated_at: String, #[serde_as(as = "DefaultOnNull")] pub parent_id: i64, } @@ -297,7 +300,7 @@ pub fn download( } false => { // Create a ZIP - println!("Creating ZIP..."); + println!("Creating ZIP for \"{}\"...", files.parent.name); let zip_url: String = put::zips::create(client, api_token, files.parent.id) .expect("creating zip job"); From a8ea562ac56136a6e106c1cec346697943dcc397 Mon Sep 17 00:00:00 2001 From: Michael O'Brien Date: Sun, 22 Feb 2026 12:02:14 -0500 Subject: [PATCH 03/13] better spinner --- src/browse/app.rs | 2 ++ src/browse/events.rs | 2 ++ src/browse/mod.rs | 73 +++++++++++++++++++++++++++++++++----------- src/browse/ui.rs | 43 +++++++++++++++----------- 4 files changed, 86 insertions(+), 34 deletions(-) diff --git a/src/browse/app.rs b/src/browse/app.rs index 4045ac9..1383f52 100644 --- a/src/browse/app.rs +++ b/src/browse/app.rs @@ -98,6 +98,7 @@ pub struct BrowserApp { restore_offset: Option, pub tick: u8, pub needs_reload: bool, + pub spinner_label: String, pub last_search: Option, pub is_search_results: bool, pub pending_select_id: Option, @@ -122,6 +123,7 @@ impl BrowserApp { restore_offset: None, tick: 0, needs_reload: true, + spinner_label: "Loading...".to_string(), last_search: None, is_search_results: false, pending_select_id: None, diff --git a/src/browse/events.rs b/src/browse/events.rs index e0c8180..cdfb90e 100644 --- a/src/browse/events.rs +++ b/src/browse/events.rs @@ -24,6 +24,7 @@ pub fn handle_key(app: &mut BrowserApp, key: KeyEvent, client: &Client, api_toke match put::files::delete(client, api_token, &file_id.to_string()) { Ok(_) => { app.save_position_for_reload(); + app.spinner_label = "Deleting...".to_string(); app.modal = ModalState::Loading; app.needs_reload = true; } @@ -90,6 +91,7 @@ pub fn handle_key(app: &mut BrowserApp, key: KeyEvent, client: &Client, api_toke KeyCode::Enter => { if !query.is_empty() { app.pending_action = PendingAction::Search { query }; + app.spinner_label = "Searching...".to_string(); app.modal = ModalState::Loading; } else { app.modal = ModalState::None; diff --git a/src/browse/mod.rs b/src/browse/mod.rs index 51a08c5..0ddc7fd 100644 --- a/src/browse/mod.rs +++ b/src/browse/mod.rs @@ -3,6 +3,8 @@ mod events; mod ui; use std::io; +use std::sync::mpsc; +use std::time::Duration; use crossterm::{ event::{self, Event}, @@ -39,20 +41,46 @@ pub fn run(client: &Client, api_token: &String) -> io::Result<()> { break; } - // Draw happens before the load so the spinner is visible during the request. if app.needs_reload { app.needs_reload = false; - load_current_folder(&mut app, client, api_token); + if !matches!(app.modal, ModalState::Loading) { + app.spinner_label = "Loading...".to_string(); + app.modal = ModalState::Loading; + } + let client2 = client.clone(); + let token2 = api_token.clone(); + let folder_id = app.current_folder_id; + let result = spin_while(&mut terminal, &mut app, move || { + put::files::list(&client2, &token2, folder_id) + })?; + match result { + Ok(r) => { + if app.current_folder_id != 0 { + if let Some(crumb) = app.breadcrumbs.last_mut() { + if crumb.id == app.current_folder_id { + crumb.name = r.parent.name.clone(); + } + } + } + app.set_files(r.files); + } + Err(e) => app.modal = ModalState::Error(e.to_string()), + } continue; } - // Handle pending actions that need the spinner frame to render first. let pending = std::mem::replace(&mut app.pending_action, PendingAction::None); match pending { PendingAction::None => {} PendingAction::Search { query } => { - match put::files::search(client, api_token, &query) { + let client2 = client.clone(); + let token2 = api_token.clone(); + let query2 = query.clone(); + let result = spin_while(&mut terminal, &mut app, move || { + put::files::search(&client2, &token2, &query2) + })?; + match result { Ok(r) => app.enter_search_results(&query, r.files), Err(e) => app.modal = ModalState::Error(format!("Search failed: {}", e)), } @@ -87,7 +115,7 @@ pub fn run(client: &Client, api_token: &String) -> io::Result<()> { break; } - if event::poll(std::time::Duration::from_millis(100))? { + if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { events::handle_key(&mut app, key, client, api_token); } @@ -101,19 +129,30 @@ pub fn run(client: &Client, api_token: &String) -> io::Result<()> { Ok(()) } -pub(super) fn load_current_folder(app: &mut BrowserApp, client: &Client, api_token: &String) { - match put::files::list(client, api_token, app.current_folder_id) { - Ok(r) => { - // Update the breadcrumb name from the API response (covers "Go to folder" placeholders) - if app.current_folder_id != 0 { - if let Some(crumb) = app.breadcrumbs.last_mut() { - if crumb.id == app.current_folder_id { - crumb.name = r.parent.name.clone(); - } - } +/// Runs a blocking closure on a background thread while keeping the TUI draw +/// loop alive so the spinner actually animates. +fn spin_while( + terminal: &mut Terminal>, + mut app: &mut BrowserApp, + work: F, +) -> io::Result +where + T: Send + 'static, + F: FnOnce() -> T + Send + 'static, +{ + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { tx.send(work()).ok(); }); + loop { + app.tick = app.tick.wrapping_add(1); + terminal.draw(|f| ui::draw(f, &mut app))?; + match rx.try_recv() { + Ok(result) => return Ok(result), + Err(mpsc::TryRecvError::Disconnected) => { + return Err(io::Error::new(io::ErrorKind::Other, "worker thread panicked")); + } + Err(mpsc::TryRecvError::Empty) => { + std::thread::sleep(Duration::from_millis(80)); } - app.set_files(r.files); } - Err(e) => app.modal = ModalState::Error(e.to_string()), } } diff --git a/src/browse/ui.rs b/src/browse/ui.rs index 1a02612..3942f26 100644 --- a/src/browse/ui.rs +++ b/src/browse/ui.rs @@ -30,7 +30,7 @@ pub fn draw(f: &mut Frame, app: &mut BrowserApp) { // Draw modal overlays last match &app.modal { - ModalState::Loading => draw_spinner(f, app.tick), + ModalState::Loading => draw_spinner(f, app.tick, &app.spinner_label), ModalState::Error(msg) => draw_error_modal(f, msg.clone()), ModalState::Success(msg) => draw_success_modal(f, msg.clone()), ModalState::ConfirmDelete { file_name, .. } => draw_confirm_modal(f, file_name.clone()), @@ -123,22 +123,30 @@ fn draw_help_bar(f: &mut Frame, app: &BrowserApp, area: Rect) { SortField::Modified => "Modified", }; - // 4 columns, 2 rows. Within each column: key right-aligned, label left-aligned. - // Col 1: (key_w=5, label_w=8) Navigate / Back - // Col 2: (key_w=5, label_w=6) Open / Delete - // Col 3: (key_w=1, label_w=8) Sort(field) / Reverse - // Col 4: (key_w=5, label_w=6) ^U/^D Scroll / q Quit + // 4 columns, 2 rows. Key right-aligned per column, label left-aligned. + // Col 1: key_w=5 (↑↓/jk / Bksp) + // Col 2: key_w=8 (Enter/^O / x) + // Col 3: key_w=1 (s / r) + // Col 4: key_w=5 (^U/^D / ^F) let row1 = Line::from(vec![ - Span::styled("↑↓", k), Span::styled("/", l), Span::styled("jk", k), Span::styled(format!(" {:<8}", "Navigate"), l), sep.clone(), - Span::styled(format!("{:>5}", "Enter"), k), Span::styled("/", l), Span::styled("^O", k), Span::styled(format!(" {:<6}", "Open"), l), sep.clone(), - Span::styled(format!("{:>1}", "s"), k), Span::styled(format!(" {:<8}", sort_label), l), sep.clone(), - Span::styled("^U", k), Span::styled("/", l), Span::styled("^D", k), Span::styled(format!(" {:<6}", "Scroll"), l), + Span::styled("↑↓", k), Span::styled("/", l), Span::styled("jk", k), + Span::styled(format!(" {:<8}", "Navigate"), l), sep.clone(), + Span::styled("Enter", k), Span::styled("/", l), Span::styled("^O", k), + Span::styled(format!(" {:<6}", "Open"), l), sep.clone(), + Span::styled("s", k), + Span::styled(format!(" {:<8}", sort_label), l), sep.clone(), + Span::styled("^U", k), Span::styled("/", l), Span::styled("^D", k), + Span::styled(format!(" {:<6}", "Scroll"), l), ]); let row2 = Line::from(vec![ - Span::styled(format!("{:>5}", "Bksp"), k), Span::styled(format!(" {:<8}", "Back"), l), sep.clone(), - Span::styled(format!("{:>5}", "x"), k), Span::styled(format!(" {:<6}", "Delete"), l), sep.clone(), - Span::styled(format!("{:>1}", "r"), k), Span::styled(format!(" {:<8}", "Reverse"), l), sep.clone(), - Span::styled(format!("{:>5}", "q"), k), Span::styled(format!(" {:<6}", "Quit"), l), + Span::styled(format!("{:>5}", "Bksp"), k), + Span::styled(format!(" {:<8}", "Back"), l), sep.clone(), + Span::styled(format!("{:>8}", "x"), k), + Span::styled(format!(" {:<6}", "Delete"), l), sep.clone(), + Span::styled("r", k), + Span::styled(format!(" {:<8}", "Reverse"), l), sep.clone(), + Span::styled(format!("{:>5}", "^F"), k), + Span::styled(format!(" {:<6}", "Search"), l), ]); f.render_widget( @@ -193,18 +201,19 @@ fn draw_find_bar(f: &mut Frame, query: &str) { f.set_cursor(cursor_x, y); } -fn draw_spinner(f: &mut Frame, tick: u8) { +fn draw_spinner(f: &mut Frame, tick: u8, label: &str) { const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; let ch = FRAMES[tick as usize % FRAMES.len()]; let size = f.size(); + let text = format!("{} {}", ch, label); let area = Rect { x: 1, y: size.height.saturating_sub(1), - width: 1, + width: text.chars().count() as u16, height: 1, }; f.render_widget( - Paragraph::new(ch.to_string()).style(Style::default().fg(Color::Yellow)), + Paragraph::new(text).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), area, ); } From 0d8604ebd6f51b82c5f86f5ed7baa52e3242076e Mon Sep 17 00:00:00 2001 From: David Chalifoux Date: Tue, 24 Feb 2026 18:38:30 -0500 Subject: [PATCH 04/13] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/browse/events.rs | 20 ++++++++++++++++++-- src/put/files.rs | 4 +++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/browse/events.rs b/src/browse/events.rs index cdfb90e..e735f84 100644 --- a/src/browse/events.rs +++ b/src/browse/events.rs @@ -267,8 +267,24 @@ fn execute_file_action( } fn open_in_browser(app: &mut BrowserApp, url: &str) { - let cmd = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; - match std::process::Command::new(cmd).arg(url).spawn() { + let mut command = if cfg!(target_os = "macos") { + let mut cmd = std::process::Command::new("open"); + cmd.arg(url); + cmd + } else if cfg!(target_os = "windows") { + // Use the default browser on Windows via the shell + let mut cmd = std::process::Command::new("cmd"); + cmd.args(&["/C", "start", ""]); + cmd.arg(url); + cmd + } else { + // Fallback for Unix-like systems + let mut cmd = std::process::Command::new("xdg-open"); + cmd.arg(url); + cmd + }; + + match command.spawn() { Ok(_) => app.modal = ModalState::Success("Opening in browser...".to_string()), Err(e) => app.modal = ModalState::Error(format!("Could not open browser: {}", e)), } diff --git a/src/put/files.rs b/src/put/files.rs index 4cda3fe..752e54a 100644 --- a/src/put/files.rs +++ b/src/put/files.rs @@ -54,13 +54,15 @@ pub fn resolve_path(client: &Client, api_token: &String, path: &str) -> Result { From 0d942e22f997c48719f9771a5d81d1b0af9e36cc Mon Sep 17 00:00:00 2001 From: David Chalifoux Date: Tue, 24 Feb 2026 21:45:26 -0500 Subject: [PATCH 05/13] Improve file-actions rows pad --- src/browse/ui.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/browse/ui.rs b/src/browse/ui.rs index 3942f26..8afc7ef 100644 --- a/src/browse/ui.rs +++ b/src/browse/ui.rs @@ -283,12 +283,20 @@ fn draw_file_actions_modal(f: &mut Frame, file_name: &str, file_type: &str, sele Style::default().bg(MODAL_BG).fg(Color::DarkGray), ) }; + let cursor_text = format!(" {} ", cursor); + let key_text = format!("[{}] ", key); + let label_text = label.to_string(); + let content_width = cursor_text.chars().count() + + key_text.chars().count() + + label_text.chars().count(); + let pad_width = inner.width.saturating_sub(content_width as u16) as usize; + ListItem::new(Line::from(vec![ - Span::styled(format!(" {} ", cursor), row_style), - Span::styled(format!("[{}] ", key), key_style), - Span::styled(label.to_string(), row_style), + Span::styled(cursor_text, row_style), + Span::styled(key_text, key_style), + Span::styled(label_text, row_style), // Fill the rest of the row so the highlight spans the full width - Span::styled(" ".repeat(30), row_style), + Span::styled(" ".repeat(pad_width), row_style), ])) }) .collect(); From 1335572f0da33bb3d2d4837a36094c0c6172ba35 Mon Sep 17 00:00:00 2001 From: David Chalifoux Date: Tue, 24 Feb 2026 21:57:58 -0500 Subject: [PATCH 06/13] Manually build path for copy path, fixes path in search. --- src/browse/events.rs | 63 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/src/browse/events.rs b/src/browse/events.rs index e735f84..21e68c4 100644 --- a/src/browse/events.rs +++ b/src/browse/events.rs @@ -235,18 +235,21 @@ fn execute_file_action( copy_to_clipboard(app, &file_id.to_string(), "File ID copied!"); } "Copy path" => { - let file_name = app.files.iter() - .find(|f| f.id == file_id) - .map(|f| f.name.clone()) - .unwrap_or_default(); - let mut parts: Vec = app.breadcrumbs - .iter() - .skip(1) // skip root "My Files" - .map(|b| b.name.clone()) - .collect(); - parts.push(file_name); - let path = parts.join("/"); - copy_to_clipboard(app, &path, "Path copied!"); + let (file_name, parent_id) = match app.files.iter().find(|f| f.id == file_id) { + Some(file) => (file.name.clone(), file.parent_id), + None => { + app.modal = ModalState::Error("File not found for path lookup.".to_string()); + return; + } + }; + match build_path_parts(client, api_token, parent_id) { + Ok(mut parts) => { + parts.push(file_name); + let path = parts.join("/"); + copy_to_clipboard(app, &path, "Path copied!"); + } + Err(e) => app.modal = ModalState::Error(e), + } } "Download as zip" => { app.pending_action = PendingAction::Download { file_id }; @@ -266,6 +269,42 @@ fn execute_file_action( let _ = file_type; } +fn build_path_parts( + client: &Client, + api_token: &String, + mut parent_id: i64, +) -> Result, String> { + if parent_id < 0 { + return Err("Path lookup failed: invalid parent id.".to_string()); + } + + let mut parts = Vec::new(); + let mut depth = 0; + while parent_id != 0 { + depth += 1; + if depth > 256 { + return Err("Path lookup failed: path too deep.".to_string()); + } + + let response = put::files::list(client, api_token, parent_id) + .map_err(|e| format!("Path lookup failed: {}", e))?; + let folder = response.parent; + + if folder.name.is_empty() { + return Err("Path lookup failed: missing folder name.".to_string()); + } + if folder.parent_id == parent_id { + return Err("Path lookup failed: parent loop detected.".to_string()); + } + + parts.push(folder.name); + parent_id = folder.parent_id; + } + + parts.reverse(); + Ok(parts) +} + fn open_in_browser(app: &mut BrowserApp, url: &str) { let mut command = if cfg!(target_os = "macos") { let mut cmd = std::process::Command::new("open"); From bf658f63ad806ae7f9fc31df5b224606697bcab5 Mon Sep 17 00:00:00 2001 From: David Chalifoux Date: Tue, 24 Feb 2026 22:01:39 -0500 Subject: [PATCH 07/13] Delete spinner --- src/browse/app.rs | 1 + src/browse/events.rs | 15 ++++----------- src/browse/mod.rs | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/browse/app.rs b/src/browse/app.rs index 1383f52..e32a6a5 100644 --- a/src/browse/app.rs +++ b/src/browse/app.rs @@ -81,6 +81,7 @@ pub enum PendingAction { Download { file_id: i64 }, Search { query: String }, GoToFolder { parent_id: i64, file_id: i64 }, + Delete { file_id: i64 }, } pub struct BrowserApp { diff --git a/src/browse/events.rs b/src/browse/events.rs index 21e68c4..074b7b6 100644 --- a/src/browse/events.rs +++ b/src/browse/events.rs @@ -21,17 +21,10 @@ pub fn handle_key(app: &mut BrowserApp, key: KeyEvent, client: &Client, api_toke let file_id = *file_id; match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { - match put::files::delete(client, api_token, &file_id.to_string()) { - Ok(_) => { - app.save_position_for_reload(); - app.spinner_label = "Deleting...".to_string(); - app.modal = ModalState::Loading; - app.needs_reload = true; - } - Err(e) => { - app.modal = ModalState::Error(format!("Delete failed: {}", e)); - } - } + app.save_position_for_reload(); + app.pending_action = PendingAction::Delete { file_id }; + app.spinner_label = "Deleting...".to_string(); + app.modal = ModalState::Loading; } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { app.modal = ModalState::None; diff --git a/src/browse/mod.rs b/src/browse/mod.rs index 0ddc7fd..28fca61 100644 --- a/src/browse/mod.rs +++ b/src/browse/mod.rs @@ -91,6 +91,22 @@ pub fn run(client: &Client, api_token: &String) -> io::Result<()> { app.needs_reload = true; } + PendingAction::Delete { file_id } => { + let client2 = client.clone(); + let token2 = api_token.clone(); + let file_id_str = file_id.to_string(); + let result = spin_while(&mut terminal, &mut app, move || { + put::files::delete(&client2, &token2, &file_id_str) + })?; + match result { + Ok(_) => { + app.spinner_label = "Loading...".to_string(); + app.needs_reload = true; + } + Err(e) => app.modal = ModalState::Error(format!("Delete failed: {}", e)), + } + } + PendingAction::Download { file_id } => { disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; From 092f1f0d36f03c9932df690564f0897c365e516a Mon Sep 17 00:00:00 2001 From: David Chalifoux Date: Tue, 24 Feb 2026 22:03:41 -0500 Subject: [PATCH 08/13] Copy path spinner --- src/browse/app.rs | 1 + src/browse/events.rs | 15 +++++---------- src/browse/mod.rs | 16 ++++++++++++++++ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/browse/app.rs b/src/browse/app.rs index e32a6a5..ff45f51 100644 --- a/src/browse/app.rs +++ b/src/browse/app.rs @@ -82,6 +82,7 @@ pub enum PendingAction { Search { query: String }, GoToFolder { parent_id: i64, file_id: i64 }, Delete { file_id: i64 }, + CopyPath { file_name: String, parent_id: i64 }, } pub struct BrowserApp { diff --git a/src/browse/events.rs b/src/browse/events.rs index 074b7b6..f6b9709 100644 --- a/src/browse/events.rs +++ b/src/browse/events.rs @@ -235,14 +235,9 @@ fn execute_file_action( return; } }; - match build_path_parts(client, api_token, parent_id) { - Ok(mut parts) => { - parts.push(file_name); - let path = parts.join("/"); - copy_to_clipboard(app, &path, "Path copied!"); - } - Err(e) => app.modal = ModalState::Error(e), - } + app.pending_action = PendingAction::CopyPath { file_name, parent_id }; + app.spinner_label = "Copying path...".to_string(); + app.modal = ModalState::Loading; } "Download as zip" => { app.pending_action = PendingAction::Download { file_id }; @@ -262,7 +257,7 @@ fn execute_file_action( let _ = file_type; } -fn build_path_parts( +pub(super) fn build_path_parts( client: &Client, api_token: &String, mut parent_id: i64, @@ -322,7 +317,7 @@ fn open_in_browser(app: &mut BrowserApp, url: &str) { } } -fn copy_to_clipboard(app: &mut BrowserApp, text: &str, success_msg: &str) { +pub(super) fn copy_to_clipboard(app: &mut BrowserApp, text: &str, success_msg: &str) { match arboard::Clipboard::new() { Ok(mut cb) => match cb.set_text(text) { Ok(_) => app.modal = ModalState::Success(success_msg.to_string()), diff --git a/src/browse/mod.rs b/src/browse/mod.rs index 28fca61..1d85e27 100644 --- a/src/browse/mod.rs +++ b/src/browse/mod.rs @@ -91,6 +91,22 @@ pub fn run(client: &Client, api_token: &String) -> io::Result<()> { app.needs_reload = true; } + PendingAction::CopyPath { file_name, parent_id } => { + let client2 = client.clone(); + let token2 = api_token.clone(); + let result = spin_while(&mut terminal, &mut app, move || { + events::build_path_parts(&client2, &token2, parent_id) + })?; + match result { + Ok(mut parts) => { + parts.push(file_name); + let path = parts.join("/"); + events::copy_to_clipboard(&mut app, &path, "Path copied!"); + } + Err(e) => app.modal = ModalState::Error(e), + } + } + PendingAction::Delete { file_id } => { let client2 = client.clone(); let token2 = api_token.clone(); From 2918ffeeec4a1520b111b7dfd2563fff2c761a24 Mon Sep 17 00:00:00 2001 From: David Chalifoux Date: Tue, 24 Feb 2026 22:08:58 -0500 Subject: [PATCH 09/13] Clippy --- src/browse/app.rs | 130 +++++++++++++++++++++++++++++++++-------- src/browse/events.rs | 49 ++++++++++++---- src/browse/mod.rs | 20 +++++-- src/browse/ui.rs | 135 +++++++++++++++++++++++++++++-------------- src/put/files.rs | 3 +- 5 files changed, 249 insertions(+), 88 deletions(-) diff --git a/src/browse/app.rs b/src/browse/app.rs index ff45f51..b8d48f3 100644 --- a/src/browse/app.rs +++ b/src/browse/app.rs @@ -29,10 +29,22 @@ pub enum AppState { pub enum ModalState { None, Loading, - ConfirmDelete { file_id: i64, file_name: String }, - FileActions { file_id: i64, file_name: String, file_type: String, selected: usize }, - Find { query: String }, - SearchInput { query: String }, + ConfirmDelete { + file_id: i64, + file_name: String, + }, + FileActions { + file_id: i64, + file_name: String, + file_type: String, + selected: usize, + }, + Find { + query: String, + }, + SearchInput { + query: String, + }, Error(String), Success(String), } @@ -47,31 +59,79 @@ pub struct FileAction { pub fn file_actions_for(file_type: &str, in_search_results: bool) -> Vec { let mut actions = if file_type == "FOLDER" { vec![ - FileAction { label: "Download as zip", key: 'z' }, - FileAction { label: "Open in browser", key: 'b' }, - FileAction { label: "Copy path", key: 'p' }, - FileAction { label: "Copy folder ID", key: 'i' }, + FileAction { + label: "Download as zip", + key: 'z', + }, + FileAction { + label: "Open in browser", + key: 'b', + }, + FileAction { + label: "Copy path", + key: 'p', + }, + FileAction { + label: "Copy folder ID", + key: 'i', + }, ] } else if file_type == "VIDEO" { vec![ - FileAction { label: "Copy URL", key: 'c' }, - FileAction { label: "Copy Stream URL", key: 's' }, - FileAction { label: "Download", key: 'd' }, - FileAction { label: "Open in browser", key: 'b' }, - FileAction { label: "Copy path", key: 'p' }, - FileAction { label: "Copy file ID", key: 'i' }, + FileAction { + label: "Copy URL", + key: 'c', + }, + FileAction { + label: "Copy Stream URL", + key: 's', + }, + FileAction { + label: "Download", + key: 'd', + }, + FileAction { + label: "Open in browser", + key: 'b', + }, + FileAction { + label: "Copy path", + key: 'p', + }, + FileAction { + label: "Copy file ID", + key: 'i', + }, ] } else { vec![ - FileAction { label: "Copy URL", key: 'c' }, - FileAction { label: "Download", key: 'd' }, - FileAction { label: "Open in browser", key: 'b' }, - FileAction { label: "Copy path", key: 'p' }, - FileAction { label: "Copy file ID", key: 'i' }, + FileAction { + label: "Copy URL", + key: 'c', + }, + FileAction { + label: "Download", + key: 'd', + }, + FileAction { + label: "Open in browser", + key: 'b', + }, + FileAction { + label: "Copy path", + key: 'p', + }, + FileAction { + label: "Copy file ID", + key: 'i', + }, ] }; if in_search_results { - actions.push(FileAction { label: "Go to folder", key: 'g' }); + actions.push(FileAction { + label: "Go to folder", + key: 'g', + }); } actions } @@ -112,7 +172,12 @@ impl BrowserApp { list_state.select(Some(0)); BrowserApp { current_folder_id: 0, - breadcrumbs: vec![BreadcrumbEntry { id: 0, name: "My Files".to_string(), saved_index: 0, saved_offset: 0 }], + breadcrumbs: vec![BreadcrumbEntry { + id: 0, + name: "My Files".to_string(), + saved_index: 0, + saved_offset: 0, + }], files: vec![], selected_index: 0, app_state: AppState::Browsing, @@ -138,7 +203,12 @@ impl BrowserApp { current.saved_index = self.selected_index; current.saved_offset = *self.list_state.offset_mut(); } - self.breadcrumbs.push(BreadcrumbEntry { id, name, saved_index: 0, saved_offset: 0 }); + self.breadcrumbs.push(BreadcrumbEntry { + id, + name, + saved_index: 0, + saved_offset: 0, + }); self.current_folder_id = id; self.files.clear(); self.selected_index = 0; @@ -165,10 +235,16 @@ impl BrowserApp { self.files = files; self.sort_files(); let (idx, apply_scroll) = if let Some(select_id) = self.pending_select_id.take() { - let i = self.files.iter().position(|f| f.id == select_id).unwrap_or(0); + let i = self + .files + .iter() + .position(|f| f.id == select_id) + .unwrap_or(0); (i, false) // let ratatui auto-scroll to the selected item } else { - let i = self.restore_index.take() + let i = self + .restore_index + .take() .unwrap_or(0) .min(self.files.len().saturating_sub(1)); (i, true) @@ -252,7 +328,11 @@ impl BrowserApp { SortField::Date => a.created_at.cmp(&b.created_at), SortField::Modified => a.updated_at.cmp(&b.updated_at), }; - if dir == SortDirection::Desc { ord.reverse() } else { ord } + if dir == SortDirection::Desc { + ord.reverse() + } else { + ord + } }); } diff --git a/src/browse/events.rs b/src/browse/events.rs index f6b9709..45aabd4 100644 --- a/src/browse/events.rs +++ b/src/browse/events.rs @@ -33,7 +33,12 @@ pub fn handle_key(app: &mut BrowserApp, key: KeyEvent, client: &Client, api_toke } } - ModalState::FileActions { file_id, file_name, file_type, selected } => { + ModalState::FileActions { + file_id, + file_name, + file_type, + selected, + } => { // Extract owned copies so the borrow on app.modal ends. let file_id = *file_id; let selected = *selected; @@ -46,7 +51,12 @@ pub fn handle_key(app: &mut BrowserApp, key: KeyEvent, client: &Client, api_toke match key.code { KeyCode::Up | KeyCode::Char('k') => { let new = if selected == 0 { n - 1 } else { selected - 1 }; - app.modal = ModalState::FileActions { file_id, file_name, file_type, selected: new }; + app.modal = ModalState::FileActions { + file_id, + file_name, + file_type, + selected: new, + }; } KeyCode::Down | KeyCode::Char('j') => { app.modal = ModalState::FileActions { @@ -96,7 +106,9 @@ pub fn handle_key(app: &mut BrowserApp, key: KeyEvent, client: &Client, api_toke app.modal = ModalState::SearchInput { query: q }; } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { - app.modal = ModalState::SearchInput { query: query + &c.to_string() }; + app.modal = ModalState::SearchInput { + query: query + &c.to_string(), + }; } _ => {} } @@ -121,7 +133,9 @@ pub fn handle_key(app: &mut BrowserApp, key: KeyEvent, client: &Client, api_toke app.modal = ModalState::Find { query: q }; } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { - app.modal = ModalState::Find { query: query + &c.to_string() }; + app.modal = ModalState::Find { + query: query + &c.to_string(), + }; } _ => {} } @@ -141,8 +155,12 @@ pub fn handle_key(app: &mut BrowserApp, key: KeyEvent, client: &Client, api_toke } KeyCode::Up | KeyCode::Char('k') => app.move_up(), KeyCode::Down | KeyCode::Char('j') => app.move_down(), - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => app.move_page_up(), - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => app.move_page_down(), + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.move_page_up() + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.move_page_down() + } KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => { if let Some(file) = app.selected_file() { app.modal = ModalState::FileActions { @@ -176,10 +194,14 @@ pub fn handle_key(app: &mut BrowserApp, key: KeyEvent, client: &Client, api_toke app.needs_reload = true; } KeyCode::Char('/') => { - app.modal = ModalState::Find { query: String::new() }; + app.modal = ModalState::Find { + query: String::new(), + }; } KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.modal = ModalState::SearchInput { query: String::new() }; + app.modal = ModalState::SearchInput { + query: String::new(), + }; } KeyCode::Char('n') => { app.find_next(); @@ -235,7 +257,10 @@ fn execute_file_action( return; } }; - app.pending_action = PendingAction::CopyPath { file_name, parent_id }; + app.pending_action = PendingAction::CopyPath { + file_name, + parent_id, + }; app.spinner_label = "Copying path...".to_string(); app.modal = ModalState::Loading; } @@ -246,7 +271,9 @@ fn execute_file_action( copy_to_clipboard(app, &file_id.to_string(), "Folder ID copied!"); } "Go to folder" => { - let parent_id = app.files.iter() + let parent_id = app + .files + .iter() .find(|f| f.id == file_id) .map(|f| f.parent_id) .unwrap_or(0); @@ -301,7 +328,7 @@ fn open_in_browser(app: &mut BrowserApp, url: &str) { } else if cfg!(target_os = "windows") { // Use the default browser on Windows via the shell let mut cmd = std::process::Command::new("cmd"); - cmd.args(&["/C", "start", ""]); + cmd.args(["/C", "start", ""]); cmd.arg(url); cmd } else { diff --git a/src/browse/mod.rs b/src/browse/mod.rs index 1d85e27..4117cfa 100644 --- a/src/browse/mod.rs +++ b/src/browse/mod.rs @@ -14,8 +14,8 @@ use crossterm::{ use ratatui::{backend::CrosstermBackend, Terminal}; use reqwest::blocking::Client; -use app::{AppState, BrowserApp, ModalState, PendingAction}; use crate::put; +use app::{AppState, BrowserApp, ModalState, PendingAction}; pub fn run(client: &Client, api_token: &String) -> io::Result<()> { // Restore terminal on panic @@ -91,7 +91,10 @@ pub fn run(client: &Client, api_token: &String) -> io::Result<()> { app.needs_reload = true; } - PendingAction::CopyPath { file_name, parent_id } => { + PendingAction::CopyPath { + file_name, + parent_id, + } => { let client2 = client.clone(); let token2 = api_token.clone(); let result = spin_while(&mut terminal, &mut app, move || { @@ -165,7 +168,7 @@ pub fn run(client: &Client, api_token: &String) -> io::Result<()> { /// loop alive so the spinner actually animates. fn spin_while( terminal: &mut Terminal>, - mut app: &mut BrowserApp, + app: &mut BrowserApp, work: F, ) -> io::Result where @@ -173,14 +176,19 @@ where F: FnOnce() -> T + Send + 'static, { let (tx, rx) = mpsc::channel(); - std::thread::spawn(move || { tx.send(work()).ok(); }); + std::thread::spawn(move || { + tx.send(work()).ok(); + }); loop { app.tick = app.tick.wrapping_add(1); - terminal.draw(|f| ui::draw(f, &mut app))?; + terminal.draw(|f| ui::draw(f, app))?; match rx.try_recv() { Ok(result) => return Ok(result), Err(mpsc::TryRecvError::Disconnected) => { - return Err(io::Error::new(io::ErrorKind::Other, "worker thread panicked")); + return Err(io::Error::new( + io::ErrorKind::Other, + "worker thread panicked", + )); } Err(mpsc::TryRecvError::Empty) => { std::thread::sleep(Duration::from_millis(80)); diff --git a/src/browse/ui.rs b/src/browse/ui.rs index 8afc7ef..1bd84c4 100644 --- a/src/browse/ui.rs +++ b/src/browse/ui.rs @@ -1,9 +1,9 @@ use ratatui::{ - Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, BorderType, Borders, Clear, List, ListItem, Padding, Paragraph}, + Frame, }; use super::app::{file_actions_for, AppState, BrowserApp, FileAction, ModalState, SortField}; @@ -34,7 +34,12 @@ pub fn draw(f: &mut Frame, app: &mut BrowserApp) { ModalState::Error(msg) => draw_error_modal(f, msg.clone()), ModalState::Success(msg) => draw_success_modal(f, msg.clone()), ModalState::ConfirmDelete { file_name, .. } => draw_confirm_modal(f, file_name.clone()), - ModalState::FileActions { file_name, file_type, selected, .. } => { + ModalState::FileActions { + file_name, + file_type, + selected, + .. + } => { draw_file_actions_modal(f, file_name, file_type, *selected, app.is_search_results); } ModalState::Find { query } => draw_find_bar(f, query), @@ -44,7 +49,9 @@ pub fn draw(f: &mut Frame, app: &mut BrowserApp) { } fn draw_breadcrumb(f: &mut Frame, app: &BrowserApp, area: Rect) { - let crumb_style = Style::default().fg(Color::White).add_modifier(Modifier::BOLD); + let crumb_style = Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD); let sep_style = Style::default().fg(Color::DarkGray); let mut spans: Vec = vec![Span::raw(" ")]; @@ -93,7 +100,10 @@ fn draw_file_list(f: &mut Frame, app: &mut BrowserApp, area: Rect) { spans.push(Span::styled(name_trunc, name_style)); } spans.push(Span::styled(padding, name_style)); - spans.push(Span::styled(format!("{:>10}", size_str), Style::default().fg(Color::DarkGray))); + spans.push(Span::styled( + format!("{:>10}", size_str), + Style::default().fg(Color::DarkGray), + )); ListItem::new(Line::from(spans)) }) @@ -112,14 +122,16 @@ fn draw_file_list(f: &mut Frame, app: &mut BrowserApp, area: Rect) { } fn draw_help_bar(f: &mut Frame, app: &BrowserApp, area: Rect) { - let k = Style::default().fg(Color::White).add_modifier(Modifier::BOLD); + let k = Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD); let l = Style::default().fg(Color::DarkGray); let sep = Span::styled(" ", l); let sort_label = match app.sort_field { - SortField::Name => "Name ", - SortField::Size => "Size ", - SortField::Date => "Date ", + SortField::Name => "Name ", + SortField::Size => "Size ", + SortField::Date => "Date ", SortField::Modified => "Modified", }; @@ -129,24 +141,36 @@ fn draw_help_bar(f: &mut Frame, app: &BrowserApp, area: Rect) { // Col 3: key_w=1 (s / r) // Col 4: key_w=5 (^U/^D / ^F) let row1 = Line::from(vec![ - Span::styled("↑↓", k), Span::styled("/", l), Span::styled("jk", k), - Span::styled(format!(" {:<8}", "Navigate"), l), sep.clone(), - Span::styled("Enter", k), Span::styled("/", l), Span::styled("^O", k), - Span::styled(format!(" {:<6}", "Open"), l), sep.clone(), + Span::styled("↑↓", k), + Span::styled("/", l), + Span::styled("jk", k), + Span::styled(format!(" {:<8}", "Navigate"), l), + sep.clone(), + Span::styled("Enter", k), + Span::styled("/", l), + Span::styled("^O", k), + Span::styled(format!(" {:<6}", "Open"), l), + sep.clone(), Span::styled("s", k), - Span::styled(format!(" {:<8}", sort_label), l), sep.clone(), - Span::styled("^U", k), Span::styled("/", l), Span::styled("^D", k), - Span::styled(format!(" {:<6}", "Scroll"), l), + Span::styled(format!(" {:<8}", sort_label), l), + sep.clone(), + Span::styled("^U", k), + Span::styled("/", l), + Span::styled("^D", k), + Span::styled(format!(" {:<6}", "Scroll"), l), ]); let row2 = Line::from(vec![ - Span::styled(format!("{:>5}", "Bksp"), k), - Span::styled(format!(" {:<8}", "Back"), l), sep.clone(), - Span::styled(format!("{:>8}", "x"), k), - Span::styled(format!(" {:<6}", "Delete"), l), sep.clone(), + Span::styled(format!("{:>5}", "Bksp"), k), + Span::styled(format!(" {:<8}", "Back"), l), + sep.clone(), + Span::styled(format!("{:>8}", "x"), k), + Span::styled(format!(" {:<6}", "Delete"), l), + sep.clone(), Span::styled("r", k), - Span::styled(format!(" {:<8}", "Reverse"), l), sep.clone(), - Span::styled(format!("{:>5}", "^F"), k), - Span::styled(format!(" {:<6}", "Search"), l), + Span::styled(format!(" {:<8}", "Reverse"), l), + sep.clone(), + Span::styled(format!("{:>5}", "^F"), k), + Span::styled(format!(" {:<6}", "Search"), l), ]); f.render_widget( @@ -182,17 +206,28 @@ fn draw_search_input(f: &mut Frame, query: &str) { Paragraph::new(query).style(Style::default().fg(Color::White).bg(MODAL_BG)), inner, ); - let cursor_x = (inner.x + query.chars().count() as u16).min(inner.x + inner.width.saturating_sub(1)); + let cursor_x = + (inner.x + query.chars().count() as u16).min(inner.x + inner.width.saturating_sub(1)); f.set_cursor(cursor_x, inner.y); } fn draw_find_bar(f: &mut Frame, query: &str) { let size = f.size(); let y = size.height.saturating_sub(1); - let area = Rect { x: 0, y, width: size.width, height: 1 }; + let area = Rect { + x: 0, + y, + width: size.width, + height: 1, + }; f.render_widget(Clear, area); let line = Line::from(vec![ - Span::styled("/", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Span::styled( + "/", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), Span::styled(query, Style::default().fg(Color::White)), ]); f.render_widget(Paragraph::new(line), area); @@ -213,7 +248,11 @@ fn draw_spinner(f: &mut Frame, tick: u8, label: &str) { height: 1, }; f.render_widget( - Paragraph::new(text).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Paragraph::new(text).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), area, ); } @@ -252,7 +291,13 @@ fn draw_success_modal(f: &mut Frame, msg: String) { f.render_widget(p, inner); } -fn draw_file_actions_modal(f: &mut Frame, file_name: &str, file_type: &str, selected: usize, in_search_results: bool) { +fn draw_file_actions_modal( + f: &mut Frame, + file_name: &str, + file_type: &str, + selected: usize, + in_search_results: bool, +) { let actions = file_actions_for(file_type, in_search_results); let height = actions.len() as u16 + 4; // borders + vertical padding let area = centered_rect(38, height, f.size()); @@ -275,7 +320,10 @@ fn draw_file_actions_modal(f: &mut Frame, file_name: &str, file_type: &str, sele let is_sel = i == selected; let cursor = if is_sel { "▶" } else { " " }; let (row_style, key_style) = if is_sel { - let s = Style::default().bg(Color::LightCyan).fg(Color::Black).add_modifier(Modifier::BOLD); + let s = Style::default() + .bg(Color::LightCyan) + .fg(Color::Black) + .add_modifier(Modifier::BOLD); (s, s) } else { ( @@ -286,9 +334,8 @@ fn draw_file_actions_modal(f: &mut Frame, file_name: &str, file_type: &str, sele let cursor_text = format!(" {} ", cursor); let key_text = format!("[{}] ", key); let label_text = label.to_string(); - let content_width = cursor_text.chars().count() - + key_text.chars().count() - + label_text.chars().count(); + let content_width = + cursor_text.chars().count() + key_text.chars().count() + label_text.chars().count(); let pad_width = inner.width.saturating_sub(content_width as u16) as usize; ListItem::new(Line::from(vec![ @@ -323,28 +370,28 @@ fn draw_confirm_modal(f: &mut Frame, file_name: String) { fn file_type_icon(file_type: &str) -> &'static str { match file_type { - "FOLDER" => "\u{f07b}", // - "VIDEO" => "\u{f03d}", // - "AUDIO" => "\u{f001}", // - "IMAGE" => "\u{f03e}", // + "FOLDER" => "\u{f07b}", // + "VIDEO" => "\u{f03d}", // + "AUDIO" => "\u{f001}", // + "IMAGE" => "\u{f03e}", // "ARCHIVE" => "\u{f1c6}", // - "PDF" => "\u{f1c1}", // - "TEXT" => "\u{f15c}", // - _ => "\u{f15b}", // + "PDF" => "\u{f1c1}", // + "TEXT" => "\u{f15c}", // + _ => "\u{f15b}", // } } fn file_type_color(file_type: &str) -> Color { match file_type { // Folders: bright warm yellow — visually dominant - "FOLDER" => Color::LightYellow, + "FOLDER" => Color::LightYellow, // Files: standard (non-bright) colors, clearly subordinate to folders - "VIDEO" => Color::Green, - "AUDIO" => Color::Magenta, - "IMAGE" => Color::Cyan, + "VIDEO" => Color::Green, + "AUDIO" => Color::Magenta, + "IMAGE" => Color::Cyan, "ARCHIVE" => Color::Red, - "PDF" => Color::Red, - _ => Color::Gray, + "PDF" => Color::Red, + _ => Color::Gray, } } diff --git a/src/put/files.rs b/src/put/files.rs index 752e54a..8c673cd 100644 --- a/src/put/files.rs +++ b/src/put/files.rs @@ -56,8 +56,7 @@ pub fn resolve_path(client: &Client, api_token: &String, path: &str) -> Result Date: Tue, 24 Feb 2026 22:10:01 -0500 Subject: [PATCH 10/13] Clippy --- src/browse/mod.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/browse/mod.rs b/src/browse/mod.rs index 4117cfa..6db865a 100644 --- a/src/browse/mod.rs +++ b/src/browse/mod.rs @@ -185,10 +185,7 @@ where match rx.try_recv() { Ok(result) => return Ok(result), Err(mpsc::TryRecvError::Disconnected) => { - return Err(io::Error::new( - io::ErrorKind::Other, - "worker thread panicked", - )); + return Err(io::Error::other("worker thread panicked")); } Err(mpsc::TryRecvError::Empty) => { std::thread::sleep(Duration::from_millis(80)); From 9c5c6c1f41268d10cf068a88e4b6e89d7af4ca9b Mon Sep 17 00:00:00 2001 From: David Chalifoux Date: Tue, 24 Feb 2026 22:27:33 -0500 Subject: [PATCH 11/13] Truncate before extension, remove unicode icons --- src/browse/ui.rs | 41 ++++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/browse/ui.rs b/src/browse/ui.rs index 1bd84c4..3dd536f 100644 --- a/src/browse/ui.rs +++ b/src/browse/ui.rs @@ -73,7 +73,6 @@ fn draw_file_list(f: &mut Frame, app: &mut BrowserApp, area: Rect) { .enumerate() .map(|(i, file)| { let cursor = if i == app.selected_index { ">>" } else { " " }; - let icon = file_type_icon(&file.file_type); let color = file_type_color(&file.file_type); let is_folder = file.file_type == "FOLDER"; let name_style = if is_folder { @@ -86,13 +85,10 @@ fn draw_file_list(f: &mut Frame, app: &mut BrowserApp, area: Rect) { } else { file.size.to_string() }; - let name_trunc = truncate(&file.name, 55); - let padding = " ".repeat(55usize.saturating_sub(name_trunc.chars().count()) + 1); + let name_trunc = truncate(&file.name, 64); + let padding = " ".repeat(64usize.saturating_sub(name_trunc.chars().count()) + 1); - let mut spans = vec![ - Span::raw(format!("{} ", cursor)), - Span::styled(format!("{} ", icon), name_style), - ]; + let mut spans = vec![Span::raw(format!("{} ", cursor))]; if let Some(ref query) = search { let match_style = name_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED); spans.extend(highlight_match(&name_trunc, query, name_style, match_style)); @@ -303,7 +299,7 @@ fn draw_file_actions_modal( let area = centered_rect(38, height, f.size()); f.render_widget(Clear, area); - let title = format!(" {} ", truncate(file_name, 28)); + let title = format!(" {} ", truncate(file_name, 64)); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) @@ -368,19 +364,6 @@ fn draw_confirm_modal(f: &mut Frame, file_name: String) { f.render_widget(p, inner); } -fn file_type_icon(file_type: &str) -> &'static str { - match file_type { - "FOLDER" => "\u{f07b}", // - "VIDEO" => "\u{f03d}", // - "AUDIO" => "\u{f001}", // - "IMAGE" => "\u{f03e}", // - "ARCHIVE" => "\u{f1c6}", // - "PDF" => "\u{f1c1}", // - "TEXT" => "\u{f15c}", // - _ => "\u{f15b}", // - } -} - fn file_type_color(file_type: &str) -> Color { match file_type { // Folders: bright warm yellow — visually dominant @@ -424,7 +407,23 @@ fn truncate(s: &str, max_chars: usize) -> String { let count = s.chars().count(); if count <= max_chars { s.to_string() + } else if max_chars == 0 { + String::new() + } else if max_chars == 1 { + "…".to_string() } else { + if let Some(dot) = s.rfind('.') { + if dot > 0 && dot < s.len() - 1 { + let (base, ext) = s.split_at(dot); + let ext_chars = ext.chars().count(); + if ext_chars < max_chars { + let base_chars = max_chars - ext_chars - 1; + let base_trunc: String = base.chars().take(base_chars).collect(); + return format!("{}…{}", base_trunc, ext); + } + } + } + let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect(); format!("{}…", truncated) } From 171ec1de6772e266010477ee838a356b6d912e98 Mon Sep 17 00:00:00 2001 From: David Chalifoux Date: Tue, 24 Feb 2026 22:35:08 -0500 Subject: [PATCH 12/13] Handle pagination in the list fn --- src/put/files.rs | 55 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/src/put/files.rs b/src/put/files.rs index 8c673cd..f941c8b 100644 --- a/src/put/files.rs +++ b/src/put/files.rs @@ -41,6 +41,25 @@ pub struct FilesResponse { pub parent: File, } +#[derive(Debug, Serialize, Deserialize)] +struct FilesListPageResponse { + pub files: Vec, + pub parent: File, + pub cursor: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct FilesContinueResponse { + pub files: Vec, + pub cursor: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct FilesContinueRequest { + pub cursor: String, + pub per_page: i64, +} + /// Resolves a slash-separated path (e.g. "Movies/Action/film.mkv") to a file ID /// by walking the Put.io folder tree from the root. Matching is case-insensitive. /// Returns an error string if any component is not found. @@ -79,15 +98,41 @@ pub fn resolve_path(client: &Client, api_token: &String, path: &str) -> Result Result { - let response: FilesResponse = client - .get(format!( - "https://api.put.io/v2/files/list?parent_id={parent_id}" - )) + const LIST_PAGE_SIZE: i64 = 1000; + + let response: FilesListPageResponse = client + .get("https://api.put.io/v2/files/list") + .query(&[("parent_id", parent_id), ("per_page", LIST_PAGE_SIZE)]) .header("authorization", format!("Bearer {api_token}")) .send()? .json()?; - Ok(response) + let parent = response.parent; + let mut files = response.files; + let mut cursor = response.cursor; + + while let Some(next_cursor) = cursor { + if next_cursor.is_empty() { + break; + } + + let request = FilesContinueRequest { + cursor: next_cursor, + per_page: LIST_PAGE_SIZE, + }; + + let page: FilesContinueResponse = client + .post("https://api.put.io/v2/files/list/continue") + .form(&request) + .header("authorization", format!("Bearer {api_token}")) + .send()? + .json()?; + + files.extend(page.files); + cursor = page.cursor; + } + + Ok(FilesResponse { files, parent }) } #[derive(Debug, Serialize, Deserialize)] From 5775afea4cbd8b23a48170ed895df024aa039b9d Mon Sep 17 00:00:00 2001 From: David Chalifoux Date: Tue, 24 Feb 2026 22:55:39 -0500 Subject: [PATCH 13/13] Infer download target --- src/main.rs | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/src/main.rs b/src/main.rs index e9e87ab..b24af9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use clap::{value_parser, Arg, ArgGroup, Command}; +use clap::{value_parser, Arg, Command}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -115,23 +115,10 @@ fn cli() -> Command { .about("Download a file or folder") .long_about("Downloads a file or folder from your account to your device.") .arg_required_else_help(true) - .group( - ArgGroup::new("target") - .args(["SOURCE_PATH", "id"]) - .required(true) - ) - .arg( - Arg::new("SOURCE_PATH") - .help("Path to a file or folder on Put.io (e.g. Movies/film.mkv)") - .required(false) - ) .arg( - Arg::new("id") - .short('i') - .long("id") - .value_parser(value_parser!(i64)) - .help("ID of a file or folder on Put.io") - .required(false) + Arg::new("TARGET") + .help("File ID or path on Put.io (e.g. 12345 or Movies/film.mkv)") + .required(true) ) .arg( Arg::new("path") @@ -537,14 +524,13 @@ fn main() { let recursive = sub_matches.get_flag("recursive"); let no_replace = sub_matches.get_flag("no-replace"); let dest_path = sub_matches.get_one::("path"); - let file_id = if let Some(id) = sub_matches.get_one::("id") { - *id - } else { - let source_path = sub_matches - .get_one::("SOURCE_PATH") - .expect("missing source path"); - put::files::resolve_path(&client, &config.api_token, source_path) - .unwrap_or_else(|e| panic!("Could not find '{}': {}", source_path, e)) + let target = sub_matches + .get_one::("TARGET") + .expect("missing target"); + let file_id = match target.parse::() { + Ok(id) => id, + Err(_) => put::files::resolve_path(&client, &config.api_token, target) + .unwrap_or_else(|e| panic!("Could not find '{}': {}", target, e)), }; put::files::download(