From d742fce14ae1983226fe760d5a1891ee38da43e5 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 23 Aug 2026 17:22:29 -0700 Subject: [PATCH] Implements progress reporting and pv support for the cli app. --- Cargo.lock | 55 +++++++++++++++++++++++++++++++++++++ Tasks.md | 16 ++++++----- cli/Cargo.toml | 1 + cli/src/main.rs | 72 +++++++++++++++++++++++++++++++++++-------------- 4 files changed, 117 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 450f54b..6806ce2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -580,6 +580,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "cookie" version = "0.18.2" @@ -1001,6 +1014,12 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "endi" version = "1.1.1" @@ -1876,6 +1895,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "infer" version = "0.19.0" @@ -2363,11 +2395,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "nut" version = "0.1.0" dependencies = [ "clap", + "indicatif", "nextcloud_client", "open", "reqwest 0.12.28", @@ -4476,6 +4515,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "url" version = "2.5.8" @@ -4688,6 +4733,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.6" diff --git a/Tasks.md b/Tasks.md index 7f65a81..ade0daf 100644 --- a/Tasks.md +++ b/Tasks.md @@ -69,7 +69,6 @@ This document defines the complete project roadmap and task tracking system for | ID | Title | Status | Type | |---|---|---|---| -| [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Triage | Feature | | [NUT-012](#nut-012) | Implement GUI (Tauri) Frontend | Triage | Feature | | [NUT-013](#nut-013) | Implement GUI File Queue + Drag-and-Drop | Triage | Feature | | [NUT-014](#nut-014) | Implement GUI Credential Management UI | Triage | Feature | @@ -90,6 +89,7 @@ This document defines the complete project roadmap and task tracking system for | [NUT-008](#nut-008) | Implement CLI Frontend | Fixed | Feature | | [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Fixed | Feature | | [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Fixed | Feature | +| [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Fixed | Feature | --- @@ -295,19 +295,21 @@ Support uploading multiple files in a single CLI invocation, including recursive - NUT-009 - + ### Implement CLI Progress Reporting + pv Support **ID:** NUT-011 -**Status:** Triage +**Status:** Fixed **Type:** Feature **Description:** -Integrate with `pv` for progress reporting and support streaming uploads. +Integrate rich progress reporting with `indicatif` (speed, ETA, byte counters), pipe-friendly detection (e.g. `pv` and headless/script pipes), and manual size hints for stdin streams. **Requirements:** -- [ ] Detect file size -- [ ] Report progress to stdout -- [ ] Support piping from `pv` +- [x] Detect file size & support `--size` hint for stdin streams +- [x] Rich terminal progress bar with speed (MB/s), ETA, bytes transferred, and percentage +- [x] Auto-detect TTY: silence progress bars when piped to downstream tools or files +- [x] Support explicit `--no-progress` flag +- [x] Support piping directly from `pv` or Unix pipelines without display conflicts **Dependencies:** - NUT-003 diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 81d0afa..b851eb4 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -12,3 +12,4 @@ open = "5.3" url = "2.5" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +indicatif = "0.17" diff --git a/cli/src/main.rs b/cli/src/main.rs index b57204f..83f33fe 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,7 +1,8 @@ use clap::{Args, Parser, Subcommand}; +use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; use serde::Serialize; use std::fs; -use std::io::{self, Read}; +use std::io::{self, IsTerminal, Read}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -113,6 +114,14 @@ struct UploadArgs { #[arg(short = 'c', long)] continue_on_error: bool, + /// Disable terminal progress bars + #[arg(long)] + no_progress: bool, + + /// Expected size in bytes when uploading via stdin (enables accurate progress) + #[arg(long)] + size: Option, + /// Upload content from standard input (stdin) #[arg(long)] stdin: bool, @@ -265,6 +274,10 @@ async fn handle_upload(args: UploadArgs) -> Result<()> { None => CredentialStore::create_client_for_default()?, }; + let show_progress = !args.no_progress + && !args.format.is_machine_readable() + && io::stderr().is_terminal(); + let mut results: Vec = Vec::new(); // Stdin Upload @@ -287,10 +300,10 @@ async fn handle_upload(args: UploadArgs) -> Result<()> { println!("\x1b[1;34m==>\x1b[0m Uploading {} bytes to '{}'...", len, remote_path); } - let progress_cb = if args.format.is_machine_readable() { - None + let progress_cb = if show_progress { + Some(create_indicatif_progress_callback(args.size.or(Some(len)))) } else { - Some(create_progress_callback(len)) + None }; let cursor = io::Cursor::new(buffer); @@ -345,7 +358,7 @@ async fn handle_upload(args: UploadArgs) -> Result<()> { }; if !args.format.is_machine_readable() && total_files > 1 { - println!("\n\x1b[1;35m[{}/{}]\x1b[0m Processing '{}'", i + 1, total_files, local_path.display()); + println!("\n\x1b[1;35m[{}/{}]\x1b[0m '{}'", i + 1, total_files, local_path.display()); } match upload_single_file( @@ -355,6 +368,7 @@ async fn handle_upload(args: UploadArgs) -> Result<()> { args.share, args.password.as_deref(), &args.format, + show_progress, ) .await { @@ -460,6 +474,7 @@ async fn upload_single_file( create_share: bool, password: Option<&str>, format: &OutputFormatArgs, + show_progress: bool, ) -> Result { let metadata = tokio::fs::metadata(local_path).await?; let file_size = metadata.len(); @@ -468,10 +483,10 @@ async fn upload_single_file( println!("\x1b[1;34m==>\x1b[0m Uploading '{}' ({} bytes) -> '{}'...", local_path.display(), file_size, remote_path); } - let progress_cb = if format.is_machine_readable() { - None + let progress_cb = if show_progress { + Some(create_indicatif_progress_callback(Some(file_size))) } else { - Some(create_progress_callback(file_size)) + None }; let options = UploadOptions { @@ -581,21 +596,38 @@ fn render_upload_results(results: &[UploadRecord], format: &OutputFormatArgs) { } } -fn create_progress_callback(total_bytes: u64) -> ProgressCallback { +fn create_indicatif_progress_callback(total_bytes: Option) -> ProgressCallback { + let pb = match total_bytes { + Some(total) if total > 0 => { + let pb = ProgressBar::new(total); + pb.set_draw_target(ProgressDrawTarget::stderr()); + pb.set_style( + ProgressStyle::default_bar() + .template(" [{elapsed_precise}] [{bar:35.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, ETA {eta})") + .unwrap() + .progress_chars("#>-"), + ); + pb + } + _ => { + let pb = ProgressBar::new_spinner(); + pb.set_draw_target(ProgressDrawTarget::stderr()); + pb.set_style( + ProgressStyle::default_spinner() + .template(" [{elapsed_precise}] Uploaded {bytes} ({bytes_per_sec})") + .unwrap(), + ); + pb + } + }; + Arc::new(move |event| match event { - ProgressEvent::Progress { bytes_transferred, total_bytes: total } => { - let tot = total.unwrap_or(total_bytes); - if tot > 0 { - let percent = (bytes_transferred as f64 / tot as f64) * 100.0; - print!("\r \x1b[1;33mProgress:\x1b[0m {:>3.0}% ({}/{} bytes)", percent, bytes_transferred, tot); - } else { - print!("\r \x1b[1;33mProgress:\x1b[0m {} bytes transferred", bytes_transferred); - } - let _ = io::Write::flush(&mut io::stdout()); + ProgressEvent::Progress { bytes_transferred, .. } => { + pb.set_position(bytes_transferred); } ProgressEvent::Completed { total_bytes } => { - print!("\r \x1b[1;33mProgress:\x1b[0m 100% ({} bytes) ", total_bytes); - let _ = io::Write::flush(&mut io::stdout()); + pb.set_position(total_bytes); + pb.finish_and_clear(); } _ => {} })