diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..c1697a7 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,3 @@ +target/ +**/*.mkv +**/*.apk diff --git a/Cargo.lock b/Cargo.lock index 730eae8..8dd8b07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,12 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" @@ -25,7 +31,7 @@ checksum = "3b015a331cc64ebd1774ba119538573603427eaace0a1950c423ab971f903796" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.107", ] [[package]] @@ -36,7 +42,7 @@ checksum = "705339e0e4a9690e2908d2b3d049d85682cf19fbd5782494498fbf7003a6a282" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.107", ] [[package]] @@ -63,6 +69,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + [[package]] name = "bumpalo" version = "3.11.1" @@ -75,6 +87,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c" +[[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.0.78" @@ -106,7 +133,7 @@ version = "4.0.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7db700bc935f9e43e88d00b0850dae18a63773cfbec6d8e070fccf7fef89a39" dependencies = [ - "bitflags", + "bitflags 1.3.2", "clap_derive", "clap_lex", "is-terminal", @@ -121,11 +148,11 @@ version = "4.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0177313f9f02afc995627906bbd8967e2be069f5261954222dac78290c2b9014" dependencies = [ - "heck", + "heck 0.4.0", "proc-macro-error", "proc-macro2", "quote", - "syn", + "syn 1.0.107", ] [[package]] @@ -147,6 +174,19 @@ dependencies = [ "unicode-width", ] +[[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 = "core-foundation" version = "0.9.3" @@ -163,6 +203,31 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "libc", + "mio", + "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 = "cxx" version = "1.0.86" @@ -187,7 +252,7 @@ dependencies = [ "proc-macro2", "quote", "scratch", - "syn", + "syn 1.0.107", ] [[package]] @@ -204,7 +269,7 @@ checksum = "39e61fda7e62115119469c7b3591fd913ecca96fb766cfd3f2e2502ab7bc87a5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.107", ] [[package]] @@ -228,7 +293,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 1.0.107", ] [[package]] @@ -239,7 +304,7 @@ checksum = "7618812407e9402654622dd402b0a89dff9ba93badd6540781526117b92aab7e" dependencies = [ "darling_core", "quote", - "syn", + "syn 1.0.107", ] [[package]] @@ -248,6 +313,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.2.8" @@ -305,6 +376,12 @@ 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 = "form_urlencoded" version = "1.1.0" @@ -370,7 +447,7 @@ checksum = "bdfb8ce053d86b91919aad980c220b1fb8401a9394410e1c289ed7e66b61835d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.107", ] [[package]] @@ -410,6 +487,7 @@ dependencies = [ "async-recursion", "bytes", "clap", + "crossterm", "exponential-backoff", "futures", "google-drive3", @@ -420,6 +498,7 @@ dependencies = [ "mime", "mime_guess", "mktemp", + "ratatui", "rustc_version_runtime", "serde", "serde_json", @@ -460,7 +539,7 @@ dependencies = [ "chrono", "http", "hyper", - "itertools", + "itertools 0.10.5", "mime", "serde", "serde_json", @@ -481,7 +560,7 @@ dependencies = [ "http", "hyper", "hyper-rustls", - "itertools", + "itertools 0.10.5", "mime", "serde", "serde_json", @@ -515,12 +594,29 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[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.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.2.6" @@ -682,7 +778,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.3", "serde", ] @@ -726,6 +822,24 @@ dependencies = [ "either", ] +[[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.5" @@ -781,6 +895,15 @@ dependencies = [ "cfg-if", ] +[[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 = "matches" version = "0.1.9" @@ -915,6 +1038,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "1.0.1" @@ -954,7 +1083,7 @@ dependencies = [ "proc-macro-error-attr", "proc-macro2", "quote", - "syn", + "syn 1.0.107", "version_check", ] @@ -971,18 +1100,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.49" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57a8eca9f9c4ffde41714334dee777596264c7825420f521abc92b5b5deb63a5" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.23" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -1028,13 +1157,33 @@ dependencies = [ "rand_core", ] +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str", + "crossterm", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + [[package]] name = "redox_syscall" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -1086,7 +1235,7 @@ version = "0.36.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4feacf7db682c6c329c4ede12649cd36ecab0f3be5b7d74e6a20304725db4549" dependencies = [ - "bitflags", + "bitflags 1.3.2", "errno", "io-lifetimes", "libc", @@ -1127,6 +1276,12 @@ dependencies = [ "base64 0.21.0", ] +[[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.12" @@ -1176,7 +1331,7 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bc1bb97804af6631813c55739f771071e0f2ed33ee20b68c86ec505d906356c" dependencies = [ - "bitflags", + "bitflags 1.3.2", "core-foundation", "core-foundation-sys", "libc", @@ -1225,7 +1380,7 @@ checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.107", ] [[package]] @@ -1264,7 +1419,28 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 1.0.107", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", ] [[package]] @@ -1307,12 +1483,50 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[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.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[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.114", +] + [[package]] name = "syn" version = "1.0.107" @@ -1324,6 +1538,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tabwriter" version = "1.2.1" @@ -1439,7 +1664,7 @@ checksum = "d266c00fde287f55d3f1c3e96c500c362a2b8c695076ec180f27918820bc6df8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.107", ] [[package]] @@ -1529,6 +1754,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.10" @@ -1621,7 +1863,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 1.0.107", "wasm-bindgen-shared", ] @@ -1643,7 +1885,7 @@ checksum = "07bc0c051dc5f23e307b13285f9d75df86bfdf816c5721e573dec1f9b8aa193c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.107", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1784,7 +2026,7 @@ dependencies = [ "http", "hyper", "hyper-rustls", - "itertools", + "itertools 0.10.5", "log", "percent-encoding 2.2.0", "rustls", diff --git a/Cargo.toml b/Cargo.toml index e6688aa..afe44a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ edition = "2021" async-recursion = "1.0.2" bytes = "1.3.0" clap = { version = "4.0.29", features = ["derive"] } +crossterm = "0.27.0" exponential-backoff = "1.1.0" futures = "0.3.25" google-drive3 = { git = "https://github.com/prasmussen/google-apis-rs", branch = "resumable-fix" } @@ -19,6 +20,7 @@ md5 = "0.7.0" mime = "0.3.16" mime_guess = "2.0.4" mktemp = "0.5.0" +ratatui = "0.26.3" rustc_version_runtime = "0.2.1" serde = { version = "1.0.151", features = ["derive"] } serde_json = "1.0.89" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2876894 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} + +ARG TARGET +ENV TARGET=${TARGET} + +WORKDIR /work diff --git a/README.md b/README.md index cf45180..23bc3c7 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,19 @@ you will help support: - You will be redirected to `http://localhost:8085` (gdrive starts a temporary web server) which completes the setup - Gdrive is now ready to use! +## Usage + +### Navigate (TUI) + +The `navigate` command opens an interactive terminal UI for browsing Drive and performing actions. + +- Start the UI: `gdrive navigate` +- Navigate: `↑/↓` to move, `Enter`/`→` to open folders, `←`/`b` to go back +- Download: press `d`, enter destination (empty = current directory) +- Upload: press `u` to open the upload picker, `Enter` to select, `u` to start upload +- Delete: press `x`, confirm with `y` or cancel with `n`/`Esc` +- Quit: press `q` (if transfers are active, a confirmation dialog appears) + ### Using gdrive on a remote server Part of the flow for adding an account to gdrive requires your web browser to access `localhost:8085` on the machine that runs gdrive. diff --git a/dockerbuild.sh b/dockerbuild.sh new file mode 100755 index 0000000..4a55e39 --- /dev/null +++ b/dockerbuild.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET="${1:-aarch64-unknown-linux-musl}" +case "${TARGET}" in + aarch64-unknown-linux-musl) + BASE_IMAGE="messense/rust-musl-cross:aarch64-musl" + ;; + armv7-unknown-linux-musleabihf) + BASE_IMAGE="messense/rust-musl-cross:armv7-musleabihf" + ;; + *) + echo "Unsupported target: ${TARGET}" + exit 1 + ;; +esac + +IMAGE="gdrive-build:${TARGET}" + +docker build \ + --build-arg TARGET="${TARGET}" \ + --build-arg BASE_IMAGE="${BASE_IMAGE}" \ + -t "${IMAGE}" \ + -f Dockerfile \ + . diff --git a/indockerbuild.sh b/indockerbuild.sh new file mode 100755 index 0000000..ca10198 --- /dev/null +++ b/indockerbuild.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET="${1:-aarch64-unknown-linux-musl}" +OUT_DIR="${2:-$(pwd)/dist/${TARGET}}" +IMAGE="gdrive-build:${TARGET}" + +mkdir -p "${OUT_DIR}" + +docker run --rm \ + -v "$(pwd)":/work \ + -v "${OUT_DIR}":/out \ + -w /work \ + "${IMAGE}" \ + /bin/sh -c "cargo build --release --target ${TARGET} && cp target/${TARGET}/release/gdrive /out/gdrive" diff --git a/src/main.rs b/src/main.rs index b309066..fd4b03d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ pub mod common; pub mod drives; pub mod files; pub mod hub; +pub mod navigate; pub mod permissions; pub mod version; @@ -55,6 +56,9 @@ enum Command { /// Print version information Version, + + /// Open interactive TUI + Navigate, } #[derive(Subcommand)] @@ -714,6 +718,11 @@ async fn main() { } } + Command::Navigate => { + // fmt + navigate::navigate().await.unwrap_or_else(handle_error) + } + Command::Version => { // fmt version::version() diff --git a/src/navigate.rs b/src/navigate.rs new file mode 100644 index 0000000..e259d41 --- /dev/null +++ b/src/navigate.rs @@ -0,0 +1,1503 @@ +use crate::common::delegate::{BackoffConfig, ChunkSize, UploadDelegateConfig}; +use crate::common::drive_file; +use crate::common::file_info; +use crate::common::file_tree; +use crate::common::hub_helper; +use crate::common::id_gen::IdGen; +use crate::common::md5_writer::Md5Writer; +use crate::files; +use crate::files::info::DisplayConfig; +use crate::files::list::{ListFilesConfig, ListQuery, ListSortOrder}; +use crate::files::mkdir; +use crate::files::upload; +use crate::hub::Hub; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; +use futures::StreamExt; +use human_bytes::human_bytes; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; +use ratatui::Terminal; +use std::error; +use std::fmt::{Display, Formatter}; +use std::io; +use std::io::Write; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; +use tokio::runtime::Handle; + +const HELP_LABELS: [(&str, &str); 7] = [ + ("Enter/→ : open", "open"), + ("←/b : back", "back"), + ("d : download", "download"), + ("u : upload menu", "upload"), + ("x : delete", "delete"), + ("r : refresh", "refresh"), + ("q : quit", "quit"), +]; + +pub async fn navigate() -> Result<(), Error> { + let handle = Handle::current(); + let result = tokio::task::spawn_blocking(move || run_app(handle)).await; + match result { + Ok(inner) => inner, + Err(err) => Err(Error::Join(err)), + } +} + +fn run_app(handle: Handle) -> Result<(), Error> { + enable_raw_mode().map_err(Error::Io)?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen).map_err(Error::Io)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend).map_err(Error::Io)?; + + let result = run_loop(&mut terminal, handle); + + disable_raw_mode().map_err(Error::Io)?; + execute!(terminal.backend_mut(), LeaveAlternateScreen).map_err(Error::Io)?; + terminal.show_cursor().map_err(Error::Io)?; + + result +} + +fn run_loop(terminal: &mut Terminal>, handle: Handle) -> Result<(), Error> { + let hub = handle + .block_on(hub_helper::get_hub()) + .map_err(Error::Hub)?; + let mut app = App::new(hub); + app.reload(&handle)?; + + loop { + app.tick(); + terminal.draw(|frame| draw_ui(frame, &app)).map_err(Error::Io)?; + + if app.should_exit() { + break; + } + + if !event::poll(Duration::from_millis(250)).map_err(Error::Io)? { + continue; + } + + if let Event::Key(key) = event::read().map_err(Error::Io)? { + if handle_key_event(&mut app, key, &handle)? { + break; + } + } + } + + Ok(()) +} + +fn handle_key_event(app: &mut App, key: KeyEvent, handle: &Handle) -> Result { + match app.input_mode { + InputMode::Normal => handle_normal_key(app, key, handle), + InputMode::DownloadDestination => handle_input_key(app, key, handle), + InputMode::UploadPicker => handle_upload_picker_key(app, key, handle), + InputMode::DeleteConfirm => handle_delete_confirm_key(app, key, handle), + InputMode::QuitConfirm => handle_quit_confirm_key(app, key), + } +} + +fn handle_normal_key(app: &mut App, key: KeyEvent, handle: &Handle) -> Result { + match key.code { + KeyCode::Char('q') => { + if app.can_quit() { + return Ok(true); + } + app.start_quit_confirm(); + } + KeyCode::Char('r') => { + app.reload(handle)?; + } + KeyCode::Char('b') | KeyCode::Left => { + app.go_back(handle)?; + } + KeyCode::Char('d') => { + app.start_input(InputMode::DownloadDestination, "Download destination (dir)"); + } + KeyCode::Char('u') => { + app.start_upload_picker(); + } + KeyCode::Char('x') => { + app.start_delete_confirm(); + } + KeyCode::Delete => { + app.start_delete_confirm(); + } + KeyCode::Up => { + app.select_previous(); + } + KeyCode::Down => { + app.select_next(); + } + KeyCode::Enter | KeyCode::Right => { + app.open_selected(handle)?; + } + _ => {} + } + + Ok(false) +} + +fn handle_input_key(app: &mut App, key: KeyEvent, handle: &Handle) -> Result { + match key.code { + KeyCode::Esc => { + app.cancel_input("Cancelled"); + } + KeyCode::Enter => { + let input = app.input.clone(); + let mode = app.input_mode; + app.input.clear(); + app.input_mode = InputMode::Normal; + + match mode { + InputMode::DownloadDestination => { + let destination = if input.trim().is_empty() { + None + } else { + Some(PathBuf::from(input.trim())) + }; + if let Err(err) = app.download_selected(handle, destination) { + app.status = format!("Error: {}", err); + } else { + app.status = "Download completed".to_string(); + } + } + InputMode::Normal | InputMode::UploadPicker | InputMode::DeleteConfirm | InputMode::QuitConfirm => {} + } + } + KeyCode::Backspace => { + app.input.pop(); + } + KeyCode::Char(ch) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + return Ok(false); + } + app.input.push(ch); + } + _ => {} + } + + Ok(false) +} + +fn handle_upload_picker_key( + app: &mut App, + key: KeyEvent, + handle: &Handle, +) -> Result { + let picker = match app.upload_picker.as_mut() { + Some(picker) => picker, + None => { + app.cancel_input("Upload picker closed"); + return Ok(false); + } + }; + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + app.cancel_input("Upload cancelled"); + } + KeyCode::Char('b') | KeyCode::Left => { + if let Some(parent) = picker.current_dir.parent().map(|p| p.to_path_buf()) { + picker.current_dir = parent; + picker.reload().map_err(Error::Io)?; + } + } + KeyCode::Right => { + if let Some(entry) = picker.entries.get(picker.selected) { + if entry.is_dir { + picker.current_dir = entry.path.clone(); + picker.reload().map_err(Error::Io)?; + } + } + } + KeyCode::Up => { + if !picker.entries.is_empty() { + if picker.selected == 0 { + picker.selected = picker.entries.len() - 1; + } else { + picker.selected -= 1; + } + } + } + KeyCode::Down => { + if !picker.entries.is_empty() { + picker.selected = (picker.selected + 1) % picker.entries.len(); + } + } + KeyCode::Enter => { + if let Some(entry) = picker.entries.get(picker.selected) { + if entry.is_parent { + picker.current_dir = entry.path.clone(); + picker.reload().map_err(Error::Io)?; + } else { + picker.selected_path = Some(entry.path.clone()); + app.status = format!("Selected {}", entry.name); + } + } + } + KeyCode::Char('u') => { + let selection = picker + .selected_path + .clone() + .or_else(|| picker.entries.get(picker.selected).map(|e| e.path.clone())); + match selection { + Some(path) => { + app.input_mode = InputMode::Normal; + app.upload_picker = None; + app.start_upload_job(handle, path)?; + } + None => { + app.status = "No selection to upload".to_string(); + } + } + } + _ => {} + } + + Ok(false) +} + +fn handle_delete_confirm_key( + app: &mut App, + key: KeyEvent, + handle: &Handle, +) -> Result { + match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('n') | KeyCode::Char('N') => { + app.pending_delete = None; + app.input_mode = InputMode::Normal; + app.status = "Delete cancelled".to_string(); + } + KeyCode::Char('y') | KeyCode::Char('Y') => { + if let Some(item) = app.pending_delete.clone() { + app.pending_delete = None; + app.input_mode = InputMode::Normal; + if let Err(err) = app.delete_item(handle, item) { + app.status = format!("Delete failed: {}", err); + } else { + app.status = "Delete completed".to_string(); + app.reload(handle)?; + } + } else { + app.input_mode = InputMode::Normal; + app.status = "Nothing to delete".to_string(); + } + } + _ => {} + } + + Ok(false) +} + +fn handle_quit_confirm_key(app: &mut App, key: KeyEvent) -> Result { + match key.code { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + app.input_mode = InputMode::Normal; + app.status = "Quit cancelled".to_string(); + } + KeyCode::Char('y') | KeyCode::Char('Y') => { + app.input_mode = InputMode::Normal; + app.request_exit(); + } + _ => {} + } + Ok(false) +} + +fn draw_ui(frame: &mut ratatui::Frame<'_>, app: &App) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(2), + ]) + .split(frame.size()); + + let header = Paragraph::new(Line::from(vec![ + Span::raw("Folder: "), + Span::styled( + app.current_folder_name.as_str(), + Style::default().add_modifier(Modifier::BOLD), + ), + ])); + frame.render_widget(header, layout[0]); + + let items: Vec = app + .items + .iter() + .map(|item| { + let label = if item.is_parent { + item.name.clone() + } else if item.is_folder { + format!("[DIR] {}", item.name) + } else if let Some(size) = item.size { + let formatted = + files::info::format_bytes(size, &DisplayConfig::default()); + format!("{} ({})", item.name, formatted) + } else { + item.name.clone() + }; + ListItem::new(Line::from(label)) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Drive") + .border_style(Style::default().fg(Color::LightBlue)), + ) + .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + + let mut state = ListState::default(); + if !app.items.is_empty() { + state.select(Some(app.selected)); + } + frame.render_stateful_widget(list, layout[1], &mut state); + + let footer_text = match app.input_mode { + InputMode::Normal => { + let status = app.render_status(); + let mut spans = vec![Span::raw(status), Span::raw(" | ")]; + for (index, (label, tag)) in HELP_LABELS.iter().enumerate() { + let color = match *tag { + "open" => Color::Cyan, + "back" => Color::Magenta, + "download" => Color::Yellow, + "upload" => Color::Green, + "delete" => Color::Red, + "refresh" => Color::Blue, + "quit" => Color::Red, + _ => Color::White, + }; + if index > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled(*label, Style::default().fg(color))); + } + Line::from(spans) + } + InputMode::DownloadDestination => { + let current_dir = std::env::current_dir() + .ok() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "".to_string()); + Line::from(vec![ + Span::raw(format!("Download to dir (empty = {}): ", current_dir)), + Span::styled(app.input.as_str(), Style::default().add_modifier(Modifier::BOLD)), + ]) + } + InputMode::UploadPicker => { + let selected = app + .upload_picker + .as_ref() + .and_then(|picker| picker.selected_path.clone()) + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "".to_string()); + let mut spans = vec![ + Span::raw("Upload picker "), + Span::styled("Enter: select", Style::default().fg(Color::Cyan)), + Span::raw(" "), + Span::styled("→: open dir", Style::default().fg(Color::Blue)), + Span::raw(" "), + Span::styled("←/b: up", Style::default().fg(Color::Magenta)), + Span::raw(" "), + Span::styled("u: upload", Style::default().fg(Color::Green)), + Span::raw(" "), + Span::styled("Esc: cancel", Style::default().fg(Color::Red)), + Span::raw(" | Selected: "), + Span::styled(selected, Style::default().add_modifier(Modifier::BOLD)), + ]; + if app.blink_on { + if let Some(picker) = &app.upload_picker { + if picker.selected_path.is_some() { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + "press u to start uploading", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + } + } + } + Line::from(spans) + } + InputMode::DeleteConfirm => Line::from(vec![Span::raw("Confirm delete...")]), + InputMode::QuitConfirm => Line::from(vec![Span::raw("Confirm quit...")]), + }; + + let footer = Paragraph::new(footer_text); + frame.render_widget(footer, layout[2]); + + if app.input_mode == InputMode::UploadPicker { + draw_upload_picker(frame, app); + } + if app.input_mode == InputMode::DeleteConfirm { + draw_delete_confirm(frame, app); + } + if app.input_mode == InputMode::QuitConfirm { + draw_quit_confirm(frame, app); + } +} + +fn draw_upload_picker(frame: &mut ratatui::Frame<'_>, app: &App) { + let picker = match &app.upload_picker { + Some(picker) => picker, + None => return, + }; + let area = centered_rect(80, 70, frame.size()); + frame.render_widget(Clear, area); + let entries: Vec = picker + .entries + .iter() + .map(|entry| { + let label = if entry.is_parent { + "/..".to_string() + } else if entry.is_dir { + format!("[DIR] {}", entry.name) + } else { + entry.name.clone() + }; + ListItem::new(Line::from(label)) + }) + .collect(); + + let list = List::new(entries) + .block( + Block::default() + .title(format!( + "Upload from {}", + picker.current_dir.display() + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::LightBlue)), + ) + .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + + let mut state = ListState::default(); + if !picker.entries.is_empty() { + state.select(Some(picker.selected)); + } + frame.render_stateful_widget(list, area, &mut state); +} + +fn draw_delete_confirm(frame: &mut ratatui::Frame<'_>, app: &App) { + let item = match &app.pending_delete { + Some(item) => item, + None => return, + }; + let area = centered_rect(50, 30, frame.size()); + frame.render_widget(Clear, area); + let lines = vec![ + Line::from(vec![ + Span::raw("Delete "), + Span::styled( + item.name.as_str(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw("?"), + ]), + Line::from("Are you sure?"), + Line::from(vec![ + Span::styled("[y] Yes", Style::default().fg(Color::Red)), + Span::raw(" "), + Span::styled("[n] No", Style::default().fg(Color::Green)), + ]), + ]; + + let block = Block::default() + .title("Confirm Delete") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::LightBlue)); + + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, area); +} + +fn draw_quit_confirm(frame: &mut ratatui::Frame<'_>, app: &App) { + if !app.has_active_transfer() { + return; + } + let area = centered_rect(50, 30, frame.size()); + frame.render_widget(Clear, area); + let lines = vec![ + Line::from("There are active transfers."), + Line::from("Are you sure you want to quit?"), + Line::from(vec![ + Span::styled("[y] Yes", Style::default().fg(Color::Red)), + Span::raw(" "), + Span::styled("[n] No", Style::default().fg(Color::Green)), + ]), + ]; + let block = Block::default() + .title("Confirm Quit") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::LightBlue)); + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, area); +} + +fn centered_rect(percent_x: u16, percent_y: u16, area: ratatui::layout::Rect) -> ratatui::layout::Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +#[derive(Debug, Clone)] +struct DriveItem { + id: String, + name: String, + is_folder: bool, + size: Option, + is_parent: bool, +} + +#[derive(Debug, Clone)] +struct FolderState { + id: Option, + name: String, +} + +#[derive(Debug, Clone)] +struct LocalEntry { + name: String, + path: PathBuf, + is_dir: bool, + is_parent: bool, +} + +struct UploadPicker { + current_dir: PathBuf, + entries: Vec, + selected: usize, + selected_path: Option, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +enum InputMode { + Normal, + DownloadDestination, + UploadPicker, + DeleteConfirm, + QuitConfirm, +} + +struct App { + hub: Hub, + items: Vec, + selected: usize, + folder_stack: Vec, + current_folder_id: Option, + current_folder_name: String, + status: String, + input_mode: InputMode, + input: String, + download_job: Option, + upload_picker: Option, + upload_job: Option, + blink_on: bool, + last_blink: Instant, + pending_delete: Option, + exit_requested: bool, +} + +impl App { + fn new(hub: Hub) -> Self { + Self { + hub, + items: Vec::new(), + selected: 0, + folder_stack: Vec::new(), + current_folder_id: None, + current_folder_name: "root".to_string(), + status: "Ready".to_string(), + input_mode: InputMode::Normal, + input: String::new(), + download_job: None, + upload_picker: None, + upload_job: None, + blink_on: true, + last_blink: Instant::now(), + pending_delete: None, + exit_requested: false, + } + } + + fn start_input(&mut self, mode: InputMode, status: &str) { + self.input_mode = mode; + self.input.clear(); + self.status = status.to_string(); + } + + fn cancel_input(&mut self, status: &str) { + self.input_mode = InputMode::Normal; + self.input.clear(); + self.status = status.to_string(); + self.upload_picker = None; + } + + fn start_upload_picker(&mut self) { + let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + match UploadPicker::from_dir(current_dir) { + Ok(picker) => { + self.upload_picker = Some(picker); + self.input_mode = InputMode::UploadPicker; + self.status = "Upload picker".to_string(); + } + Err(err) => { + self.status = format!("Failed to open upload picker: {}", err); + } + } + } + + fn start_quit_confirm(&mut self) { + self.input_mode = InputMode::QuitConfirm; + self.status = "Confirm quit".to_string(); + } + + fn request_exit(&mut self) { + self.exit_requested = true; + if let Some(job) = &self.upload_job { + job.cancel.store(true, Ordering::SeqCst); + } + if let Some(job) = &self.download_job { + job.cancel.store(true, Ordering::SeqCst); + } + } + + fn can_quit(&self) -> bool { + self.upload_job.is_none() && self.download_job.is_none() + } + + fn has_active_transfer(&self) -> bool { + !self.can_quit() + } + + fn should_exit(&self) -> bool { + self.exit_requested && self.can_quit() + } + + fn start_delete_confirm(&mut self) { + let item = match self.items.get(self.selected) { + Some(item) => item.clone(), + None => { + self.status = "No selection".to_string(); + return; + } + }; + if item.is_parent { + self.status = "Cannot delete parent entry".to_string(); + return; + } + if item.id.is_empty() { + self.status = "Missing file id".to_string(); + return; + } + self.pending_delete = Some(item); + self.input_mode = InputMode::DeleteConfirm; + self.status = "Confirm delete".to_string(); + } + + fn select_next(&mut self) { + if self.items.is_empty() { + return; + } + self.selected = (self.selected + 1) % self.items.len(); + } + + fn select_previous(&mut self) { + if self.items.is_empty() { + return; + } + if self.selected == 0 { + self.selected = self.items.len() - 1; + } else { + self.selected -= 1; + } + } + + fn reload(&mut self, handle: &Handle) -> Result<(), Error> { + let query = match self.current_folder_id.clone() { + Some(folder_id) => ListQuery::FilesInFolder { folder_id }, + None => ListQuery::RootNotTrashed, + }; + let files = handle + .block_on(files::list::list_files( + &self.hub, + &ListFilesConfig { + query, + order_by: ListSortOrder::default(), + max_files: 1000, + }, + )) + .map_err(Error::List)?; + + self.items = files + .into_iter() + .map(|file| DriveItem { + id: file.id.clone().unwrap_or_default(), + name: file.name.clone().unwrap_or_else(|| "".to_string()), + is_folder: drive_file::is_directory(&file), + size: file.size, + is_parent: false, + }) + .collect(); + self.items.push(DriveItem { + id: String::new(), + name: "/..".to_string(), + is_folder: true, + size: None, + is_parent: true, + }); + self.items.sort_by(|a, b| match (a.is_folder, b.is_folder) { + _ if a.is_parent && !b.is_parent => std::cmp::Ordering::Less, + _ if b.is_parent && !a.is_parent => std::cmp::Ordering::Greater, + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + }); + self.selected = 0; + self.status = "Ready".to_string(); + Ok(()) + } + + fn open_selected(&mut self, handle: &Handle) -> Result<(), Error> { + let item = match self.items.get(self.selected) { + Some(item) => item.clone(), + None => { + self.status = "No selection".to_string(); + return Ok(()); + } + }; + if !item.is_folder { + self.status = "Not a folder".to_string(); + return Ok(()); + } + if item.is_parent { + return self.go_back(handle); + } + if item.id.is_empty() { + self.status = "Missing folder id".to_string(); + return Ok(()); + } + + let previous = FolderState { + id: self.current_folder_id.clone(), + name: self.current_folder_name.clone(), + }; + self.folder_stack.push(previous); + self.current_folder_id = Some(item.id); + self.current_folder_name = item.name; + self.reload(handle) + } + + fn delete_item(&mut self, handle: &Handle, item: DriveItem) -> Result<(), Error> { + let config = files::delete::Config { + file_id: item.id, + delete_directories: item.is_folder, + }; + handle.block_on(files::delete(config)).map_err(Error::Delete) + } + + fn go_back(&mut self, handle: &Handle) -> Result<(), Error> { + let previous = match self.folder_stack.pop() { + Some(folder) => folder, + None => { + self.status = "Already at root".to_string(); + return Ok(()); + } + }; + self.current_folder_id = previous.id; + self.current_folder_name = previous.name; + self.reload(handle) + } + + fn download_selected( + &mut self, + handle: &Handle, + destination: Option, + ) -> Result<(), Error> { + if self.download_job.is_some() { + self.status = "Download already in progress".to_string(); + return Ok(()); + } + let item = match self.items.get(self.selected) { + Some(item) => item.clone(), + None => { + self.status = "No selection".to_string(); + return Ok(()); + } + }; + if item.is_folder { + self.status = "Select a file to download".to_string(); + return Ok(()); + } + if item.id.is_empty() { + self.status = "Missing file id".to_string(); + return Ok(()); + } + + let progress = DownloadProgress::new(item.name.clone()); + let shared_progress = std::sync::Arc::new(std::sync::Mutex::new(progress)); + let cancel = std::sync::Arc::new(AtomicBool::new(false)); + let progress_ref = shared_progress.clone(); + let file_id = item.id.clone(); + let destination = destination.clone(); + let handle = handle.clone(); + let cancel_ref = cancel.clone(); + let join_handle = std::thread::spawn(move || { + let result = + handle.block_on(download_with_progress(file_id, destination, progress_ref.clone(), cancel_ref)); + if let Ok(mut progress) = progress_ref.lock() { + progress.done = true; + if let Err(err) = result { + progress.error = Some(err); + } + } + }); + + self.download_job = Some(DownloadJob { + progress: shared_progress, + handle: Some(join_handle), + cancel, + }); + self.status = "Download started".to_string(); + Ok(()) + } + + fn start_upload_job(&mut self, handle: &Handle, path: PathBuf) -> Result<(), Error> { + if self.upload_job.is_some() { + self.status = "Upload already in progress".to_string(); + return Ok(()); + } + + let parents = self.current_folder_id.clone().map(|id| vec![id]); + let progress = UploadProgress::new(); + let shared_progress = std::sync::Arc::new(std::sync::Mutex::new(progress)); + let cancel = std::sync::Arc::new(AtomicBool::new(false)); + let progress_ref = shared_progress.clone(); + let handle = handle.clone(); + let cancel_ref = cancel.clone(); + let join_handle = std::thread::spawn(move || { + let result = + handle.block_on(upload_with_progress(path, parents, progress_ref.clone(), cancel_ref)); + if let Ok(mut progress) = progress_ref.lock() { + progress.done = true; + if let Err(err) = result { + progress.error = Some(err); + } + } + }); + + self.upload_job = Some(UploadJob { + progress: shared_progress, + handle: Some(join_handle), + cancel, + }); + self.status = "Upload started".to_string(); + Ok(()) + } + + fn tick(&mut self) { + if self.last_blink.elapsed() >= Duration::from_millis(500) { + self.blink_on = !self.blink_on; + self.last_blink = Instant::now(); + } + + if let Some(job) = &mut self.upload_job { + let done = job + .progress + .lock() + .map(|progress| progress.done) + .unwrap_or(false); + if done { + let mut refresh_needed = false; + if let Some(handle) = job.handle.take() { + let _ = handle.join(); + } + if let Ok(progress) = job.progress.lock() { + if let Some(error) = progress.error.clone() { + self.status = format!("Upload failed: {}", error); + } else { + self.status = "Upload completed".to_string(); + refresh_needed = true; + } + } + self.upload_job = None; + if refresh_needed { + if let Err(err) = self.reload(&Handle::current()) { + self.status = format!("Upload completed (refresh failed: {})", err); + } + } + } + } + + if let Some(job) = &mut self.download_job { + let done = job + .progress + .lock() + .map(|progress| progress.done) + .unwrap_or(false); + if done { + if let Some(handle) = job.handle.take() { + let _ = handle.join(); + } + if let Ok(progress) = job.progress.lock() { + if let Some(error) = progress.error.clone() { + self.status = format!("Download failed: {}", error); + } else { + self.status = "Download completed".to_string(); + } + } + self.download_job = None; + } + } + } + + fn render_status(&self) -> String { + if let Some(job) = &self.upload_job { + if let Ok(progress) = job.progress.lock() { + if let Some(total_files) = progress.total_files { + let current = progress + .current_file + .clone() + .unwrap_or_else(|| "".to_string()); + let file_info = if let Some(total_bytes) = progress.total_bytes { + format!( + "{} ({}/{})", + current, + human_bytes(progress.current_bytes as f64), + human_bytes(total_bytes as f64) + ) + } else { + current + }; + return format!( + "Uploading {} [{}/{}]", + file_info, + progress.done_files, + total_files + ); + } + if let Some(total_bytes) = progress.total_bytes { + return format!( + "Uploading ({}/{})", + human_bytes(progress.current_bytes as f64), + human_bytes(total_bytes as f64) + ); + } + return "Uploading...".to_string(); + } + } + + if let Some(job) = &self.download_job { + if let Ok(progress) = job.progress.lock() { + let total = progress.total_bytes; + let current = progress.current_bytes; + if let Some(total_bytes) = total { + return format!( + "Downloading {} ({}/{})", + progress.file_name, + human_bytes(current as f64), + human_bytes(total_bytes as f64) + ); + } + return format!("Downloading {} ({})", progress.file_name, human_bytes(current as f64)); + } + } + self.status.clone() + } +} + +impl UploadPicker { + fn from_dir(path: PathBuf) -> Result { + let entries = list_local_entries(&path)?; + Ok(Self { + current_dir: path, + entries, + selected: 0, + selected_path: None, + }) + } + + fn reload(&mut self) -> Result<(), io::Error> { + self.entries = list_local_entries(&self.current_dir)?; + self.selected = 0; + Ok(()) + } +} + +fn list_local_entries(path: &PathBuf) -> Result, io::Error> { + let mut entries: Vec = vec![]; + let parent_path = path.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| path.clone()); + entries.push(LocalEntry { + name: "/..".to_string(), + path: parent_path, + is_dir: true, + is_parent: true, + }); + + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let entry_path = entry.path(); + let name = entry + .file_name() + .to_string_lossy() + .to_string(); + let is_dir = entry_path.is_dir(); + if entry_path.is_file() || is_dir { + entries.push(LocalEntry { + name, + path: entry_path, + is_dir, + is_parent: false, + }); + } + } + + entries.sort_by(|a, b| { + if a.is_parent && !b.is_parent { + return std::cmp::Ordering::Less; + } + if b.is_parent && !a.is_parent { + return std::cmp::Ordering::Greater; + } + match (a.is_dir, b.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + } + }); + + Ok(entries) +} + +#[derive(Debug)] +pub enum Error { + Io(io::Error), + Hub(hub_helper::Error), + List(files::list::Error), + Download(files::download::Error), + Delete(files::delete::Error), + Upload(files::upload::Error), + Join(tokio::task::JoinError), +} + +impl error::Error for Error {} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Error::Io(err) => write!(f, "{}", err), + Error::Hub(err) => write!(f, "{}", err), + Error::List(err) => write!(f, "{}", err), + Error::Download(err) => write!(f, "{}", err), + Error::Delete(err) => write!(f, "{}", err), + Error::Upload(err) => write!(f, "{}", err), + Error::Join(err) => write!(f, "{}", err), + } + } +} + +struct UploadJob { + progress: std::sync::Arc>, + handle: Option>, + cancel: std::sync::Arc, +} + +struct UploadProgress { + current_file: Option, + current_bytes: u64, + total_bytes: Option, + done_files: u64, + total_files: Option, + done: bool, + error: Option, +} + +impl UploadProgress { + fn new() -> Self { + Self { + current_file: None, + current_bytes: 0, + total_bytes: None, + done_files: 0, + total_files: None, + done: false, + error: None, + } + } +} + +struct DownloadJob { + progress: std::sync::Arc>, + handle: Option>, + cancel: std::sync::Arc, +} + +struct DownloadProgress { + file_name: String, + current_bytes: u64, + total_bytes: Option, + done: bool, + error: Option, +} + +impl DownloadProgress { + fn new(file_name: String) -> Self { + Self { + file_name, + current_bytes: 0, + total_bytes: None, + done: false, + error: None, + } + } +} + +struct ProgressReader { + inner: R, + progress: std::sync::Arc>, + cancel: std::sync::Arc, + position: u64, +} + +impl ProgressReader { + fn new( + inner: R, + progress: std::sync::Arc>, + cancel: std::sync::Arc, + ) -> Self { + Self { + inner, + progress, + cancel, + position: 0, + } + } +} + +impl std::io::Read for ProgressReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.cancel.load(Ordering::SeqCst) { + return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "Cancelled")); + } + let count = self.inner.read(buf)?; + if count > 0 { + self.position = self.position.saturating_add(count as u64); + if let Ok(mut progress) = self.progress.lock() { + progress.current_bytes = self.position; + } + } + Ok(count) + } +} + +impl std::io::Seek for ProgressReader { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + if self.cancel.load(Ordering::SeqCst) { + return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "Cancelled")); + } + let new_pos = self.inner.seek(pos)?; + self.position = new_pos; + if let Ok(mut progress) = self.progress.lock() { + progress.current_bytes = new_pos; + } + Ok(new_pos) + } +} + +async fn download_with_progress( + file_id: String, + destination: Option, + progress: std::sync::Arc>, + cancel: std::sync::Arc, +) -> Result<(), String> { + let hub = hub_helper::get_hub().await.map_err(|err| err.to_string())?; + let file = files::info::get_file(&hub, &file_id) + .await + .map_err(|err| err.to_string())?; + + if drive_file::is_directory(&file) { + return Err("Selected item is a directory".to_string()); + } + if drive_file::is_shortcut(&file) { + return Err("Shortcuts are not supported in TUI download".to_string()); + } + + let file_name = file + .name + .clone() + .ok_or_else(|| "File does not have a name".to_string())?; + if let Ok(mut progress) = progress.lock() { + progress.file_name = file_name.clone(); + progress.total_bytes = file.size.and_then(|size| u64::try_from(size).ok()); + } + + let root_path = match destination { + Some(path) => { + if !path.exists() { + return Err(format!("Destination path '{}' does not exist", path.display())); + } + if !path.is_dir() { + return Err(format!( + "Destination path '{}' is not a directory", + path.display() + )); + } + path.canonicalize() + .map_err(|err| format!("Failed to canonicalize destination: {}", err))? + } + None => std::path::PathBuf::from(".") + .canonicalize() + .map_err(|err| format!("Failed to canonicalize destination: {}", err))?, + }; + + let file_path = root_path.join(&file_name); + if file_path.exists() { + return Err(format!( + "File '{}' already exists, delete it or use a different destination", + file_path.display() + )); + } + + let body = files::download::download_file(&hub, &file_id) + .await + .map_err(|err| err.to_string())?; + + save_body_to_file_with_progress( + body, + &file_path, + file.md5_checksum.clone(), + progress, + cancel, + ) + .await +} + +async fn save_body_to_file_with_progress( + mut body: hyper::Body, + file_path: &PathBuf, + expected_md5: Option, + progress: std::sync::Arc>, + cancel: std::sync::Arc, +) -> Result<(), String> { + let tmp_file_path = file_path.with_extension("incomplete"); + let file = std::fs::File::create(&tmp_file_path).map_err(|err| err.to_string())?; + let mut writer = Md5Writer::new(file); + let mut total_written: u64 = 0; + + while let Some(chunk_result) = body.next().await { + if cancel.load(Ordering::SeqCst) { + let _ = std::fs::remove_file(&tmp_file_path); + return Err("Cancelled".to_string()); + } + let chunk = chunk_result.map_err(|err| err.to_string())?; + writer.write_all(&chunk).map_err(|err| err.to_string())?; + total_written = total_written.saturating_add(chunk.len() as u64); + if let Ok(mut progress) = progress.lock() { + progress.current_bytes = total_written; + } + } + + let actual_md5 = writer.md5(); + if let Some(expected) = expected_md5 { + if expected != actual_md5 { + return Err(format!( + "MD5 mismatch, expected: {}, actual: {}", + expected, actual_md5 + )); + } + } + + std::fs::rename(&tmp_file_path, &file_path).map_err(|err| err.to_string()) +} + +async fn upload_with_progress( + path: PathBuf, + parents: Option>, + progress: std::sync::Arc>, + cancel: std::sync::Arc, +) -> Result<(), String> { + let hub = hub_helper::get_hub().await.map_err(|err| err.to_string())?; + let delegate_config = UploadDelegateConfig { + chunk_size: ChunkSize::default(), + backoff_config: BackoffConfig { + max_retries: 100000, + min_sleep: Duration::from_secs(1), + max_sleep: Duration::from_secs(60), + }, + print_chunk_errors: false, + print_chunk_info: false, + }; + + if path.is_dir() { + upload_directory_with_progress( + &hub, + path, + parents, + delegate_config, + progress, + cancel, + ) + .await + } else { + upload_single_file_with_progress( + &hub, + path, + parents, + delegate_config, + progress, + cancel, + ) + .await + } +} + +async fn upload_single_file_with_progress( + hub: &Hub, + path: PathBuf, + parents: Option>, + delegate_config: UploadDelegateConfig, + progress: std::sync::Arc>, + cancel: std::sync::Arc, +) -> Result<(), String> { + if cancel.load(Ordering::SeqCst) { + return Err("Cancelled".to_string()); + } + let file = std::fs::File::open(&path).map_err(|err| err.to_string())?; + let file_info = file_info::FileInfo::from_file( + &file, + &file_info::Config { + file_path: path.clone(), + mime_type: None, + parents, + }, + ) + .map_err(|err| err.to_string())?; + + if let Ok(mut progress) = progress.lock() { + progress.current_file = Some(file_info.name.clone()); + progress.total_bytes = Some(file_info.size); + progress.current_bytes = 0; + progress.done_files = 0; + progress.total_files = Some(1); + } + + let reader = ProgressReader::new(file, progress.clone(), cancel); + upload::upload_file(hub, reader, None, file_info, delegate_config) + .await + .map_err(|err| err.to_string())?; + + if let Ok(mut progress) = progress.lock() { + progress.done_files = 1; + } + + Ok(()) +} + +async fn upload_directory_with_progress( + hub: &Hub, + path: PathBuf, + parents: Option>, + delegate_config: UploadDelegateConfig, + progress: std::sync::Arc>, + cancel: std::sync::Arc, +) -> Result<(), String> { + if cancel.load(Ordering::SeqCst) { + return Err("Cancelled".to_string()); + } + let mut ids = IdGen::new(hub, &delegate_config); + let tree = file_tree::FileTree::from_path(&path, &mut ids) + .await + .map_err(|err| err.to_string())?; + + let tree_info = tree.info(); + if let Ok(mut progress) = progress.lock() { + progress.total_files = Some(tree_info.file_count as u64); + progress.done_files = 0; + } + + for folder in tree.folders() { + if cancel.load(Ordering::SeqCst) { + return Err("Cancelled".to_string()); + } + let folder_parents = folder + .parent + .as_ref() + .map(|p| vec![p.drive_id.clone()]) + .or_else(|| parents.clone()); + + let drive_folder = mkdir::create_directory( + hub, + &mkdir::Config { + id: Some(folder.drive_id.clone()), + name: folder.name.clone(), + parents: folder_parents, + print_only_id: false, + }, + delegate_config.clone(), + ) + .await + .map_err(|err| err.to_string())?; + + let folder_id = drive_folder.id.ok_or("Folder created on drive has no id")?; + let file_parents = Some(vec![folder_id.clone()]); + + for file in folder.files() { + if cancel.load(Ordering::SeqCst) { + return Err("Cancelled".to_string()); + } + if let Ok(mut progress) = progress.lock() { + progress.current_file = Some(file.relative_path().display().to_string()); + progress.total_bytes = Some(file.size); + progress.current_bytes = 0; + } + + let os_file = std::fs::File::open(&file.path).map_err(|err| err.to_string())?; + let reader = ProgressReader::new(os_file, progress.clone(), cancel.clone()); + let file_info = file.info(file_parents.clone()); + + upload::upload_file( + hub, + reader, + Some(file.drive_id.clone()), + file_info, + delegate_config.clone(), + ) + .await + .map_err(|err| err.to_string())?; + + if let Ok(mut progress) = progress.lock() { + progress.done_files = progress.done_files.saturating_add(1); + } + } + } + + Ok(()) +}