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..b8d48f3 --- /dev/null +++ b/src/browse/app.rs @@ -0,0 +1,425 @@ +use crate::put::files::File; + +#[derive(Clone, Copy, PartialEq)] +pub enum SortField { + Name, + Size, + Date, + Modified, +} + +#[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), +} + +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 { + 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![ + 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 +} + +pub enum PendingAction { + None, + Download { file_id: i64 }, + Search { query: String }, + GoToFolder { parent_id: i64, file_id: i64 }, + Delete { file_id: i64 }, + CopyPath { file_name: String, parent_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 spinner_label: String, + 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, + spinner_label: "Loading...".to_string(), + 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), + SortField::Modified => a.updated_at.cmp(&b.updated_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::Modified, + SortField::Modified => 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..45aabd4 --- /dev/null +++ b/src/browse/events.rs @@ -0,0 +1,355 @@ +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') => { + 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; + } + _ => {} + } + } + + 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 actions = file_actions_for(&file_type, in_search); + let n = actions.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 label = actions[selected].label; + app.modal = ModalState::None; + execute_file_action(app, label, file_id, &file_type, api_token, client); + } + 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; + } + _ => {} + } + } + + 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.spinner_label = "Searching...".to_string(); + 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::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; + 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!"); + } + "Copy path" => { + 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; + } + }; + 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 }; + } + "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) + .map(|f| f.parent_id) + .unwrap_or(0); + app.pending_action = PendingAction::GoToFolder { parent_id, file_id }; + } + _ => {} + } + let _ = file_type; +} + +pub(super) 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"); + 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)), + } +} + +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()), + 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..6db865a --- /dev/null +++ b/src/browse/mod.rs @@ -0,0 +1,195 @@ +mod app; +mod events; +mod ui; + +use std::io; +use std::sync::mpsc; +use std::time::Duration; + +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 crate::put; +use app::{AppState, BrowserApp, ModalState, PendingAction}; + +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; + } + + if app.needs_reload { + app.needs_reload = false; + 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; + } + + let pending = std::mem::replace(&mut app.pending_action, PendingAction::None); + match pending { + PendingAction::None => {} + + PendingAction::Search { 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)), + } + } + + PendingAction::GoToFolder { parent_id, file_id } => { + app.navigate_to_folder(parent_id, file_id); + 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(); + 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)?; + 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(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(()) +} + +/// 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>, + 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, app))?; + match rx.try_recv() { + Ok(result) => return Ok(result), + Err(mpsc::TryRecvError::Disconnected) => { + return Err(io::Error::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 new file mode 100644 index 0000000..3dd536f --- /dev/null +++ b/src/browse/ui.rs @@ -0,0 +1,430 @@ +use ratatui::{ + 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}; + +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, app, chunks[2]); + + // Draw modal overlays last + match &app.modal { + 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()), + 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 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, 64); + let padding = " ".repeat(64usize.saturating_sub(name_trunc.chars().count()) + 1); + + 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)); + } 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, 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. 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("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!("{:>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( + 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, 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: text.chars().count() as u16, + height: 1, + }; + f.render_widget( + Paragraph::new(text).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + 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::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::Green).bg(MODAL_BG)); + 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, 64)); + 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, FileAction { label, key })| { + 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); + (s, s) + } else { + ( + Style::default().bg(MODAL_BG), + 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(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(pad_width), row_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_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 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) + } +} diff --git a/src/main.rs b/src/main.rs index c662614..b24af9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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)] @@ -115,10 +116,9 @@ fn cli() -> Command { .long_about("Downloads a file or folder from your account to your device.") .arg_required_else_help(true) .arg( - Arg::new("FILE_ID") - .value_parser(value_parser!(i64)) + Arg::new("TARGET") + .help("File ID or path on Put.io (e.g. 12345 or Movies/film.mkv)") .required(true) - .help("ID(s) of a file or folder (required)") ) .arg( Arg::new("path") @@ -322,6 +322,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 +523,22 @@ 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 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( &client, &config.api_token, - *file_id, + file_id, recursive, - path, + dest_path, no_replace, ) .expect("downloading file(s)"); @@ -713,6 +722,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..f941c8b 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 { @@ -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, } @@ -38,17 +41,98 @@ 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. +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 lower_part = part.to_lowercase(); + + 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() == lower_part); + + 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 - .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)] @@ -262,7 +346,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");