Implements progress reporting and pv support for the cli app.

This commit is contained in:
2026-08-23 17:22:29 -07:00
parent 38df8fb1b6
commit d742fce14a
4 changed files with 117 additions and 27 deletions

55
Cargo.lock generated
View File

@@ -580,6 +580,19 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.2" version = "0.18.2"
@@ -1001,6 +1014,12 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]] [[package]]
name = "endi" name = "endi"
version = "1.1.1" version = "1.1.1"
@@ -1876,6 +1895,19 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "infer" name = "infer"
version = "0.19.0" version = "0.19.0"
@@ -2363,11 +2395,18 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "nut" name = "nut"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"indicatif",
"nextcloud_client", "nextcloud_client",
"open", "open",
"reqwest 0.12.28", "reqwest 0.12.28",
@@ -4476,6 +4515,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4688,6 +4733,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.6" version = "0.2.6"

View File

@@ -69,7 +69,6 @@ This document defines the complete project roadmap and task tracking system for
| ID | Title | Status | Type | | 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-012](#nut-012) | Implement GUI (Tauri) Frontend | Triage | Feature |
| [NUT-013](#nut-013) | Implement GUI File Queue + Drag-and-Drop | 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 | | [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-008](#nut-008) | Implement CLI Frontend | Fixed | Feature |
| [NUT-009](#nut-009) | Implement CLI Output Formatting Options | 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-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 - NUT-009
<a id="nut-011" class="task" data-status="triage" data-task-type="feature"></a> <a id="nut-011" class="task" data-status="done" data-task-type="feature"></a>
### Implement CLI Progress Reporting + pv Support ### Implement CLI Progress Reporting + pv Support
**ID:** NUT-011 **ID:** NUT-011
**Status:** Triage **Status:** Fixed
**Type:** Feature **Type:** Feature
**Description:** **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:** **Requirements:**
- [ ] Detect file size - [x] Detect file size & support `--size` hint for stdin streams
- [ ] Report progress to stdout - [x] Rich terminal progress bar with speed (MB/s), ETA, bytes transferred, and percentage
- [ ] Support piping from `pv` - [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:** **Dependencies:**
- NUT-003 - NUT-003

View File

@@ -12,3 +12,4 @@ open = "5.3"
url = "2.5" url = "2.5"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
indicatif = "0.17"

View File

@@ -1,7 +1,8 @@
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use serde::Serialize; use serde::Serialize;
use std::fs; use std::fs;
use std::io::{self, Read}; use std::io::{self, IsTerminal, Read};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -113,6 +114,14 @@ struct UploadArgs {
#[arg(short = 'c', long)] #[arg(short = 'c', long)]
continue_on_error: bool, 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<u64>,
/// Upload content from standard input (stdin) /// Upload content from standard input (stdin)
#[arg(long)] #[arg(long)]
stdin: bool, stdin: bool,
@@ -265,6 +274,10 @@ async fn handle_upload(args: UploadArgs) -> Result<()> {
None => CredentialStore::create_client_for_default()?, 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<UploadRecord> = Vec::new(); let mut results: Vec<UploadRecord> = Vec::new();
// Stdin Upload // 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); println!("\x1b[1;34m==>\x1b[0m Uploading {} bytes to '{}'...", len, remote_path);
} }
let progress_cb = if args.format.is_machine_readable() { let progress_cb = if show_progress {
None Some(create_indicatif_progress_callback(args.size.or(Some(len))))
} else { } else {
Some(create_progress_callback(len)) None
}; };
let cursor = io::Cursor::new(buffer); 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 { 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( match upload_single_file(
@@ -355,6 +368,7 @@ async fn handle_upload(args: UploadArgs) -> Result<()> {
args.share, args.share,
args.password.as_deref(), args.password.as_deref(),
&args.format, &args.format,
show_progress,
) )
.await .await
{ {
@@ -460,6 +474,7 @@ async fn upload_single_file(
create_share: bool, create_share: bool,
password: Option<&str>, password: Option<&str>,
format: &OutputFormatArgs, format: &OutputFormatArgs,
show_progress: bool,
) -> Result<UploadRecord> { ) -> Result<UploadRecord> {
let metadata = tokio::fs::metadata(local_path).await?; let metadata = tokio::fs::metadata(local_path).await?;
let file_size = metadata.len(); 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); println!("\x1b[1;34m==>\x1b[0m Uploading '{}' ({} bytes) -> '{}'...", local_path.display(), file_size, remote_path);
} }
let progress_cb = if format.is_machine_readable() { let progress_cb = if show_progress {
None Some(create_indicatif_progress_callback(Some(file_size)))
} else { } else {
Some(create_progress_callback(file_size)) None
}; };
let options = UploadOptions { 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<u64>) -> ProgressCallback {
Arc::new(move |event| match event { let pb = match total_bytes {
ProgressEvent::Progress { bytes_transferred, total_bytes: total } => { Some(total) if total > 0 => {
let tot = total.unwrap_or(total_bytes); let pb = ProgressBar::new(total);
if tot > 0 { pb.set_draw_target(ProgressDrawTarget::stderr());
let percent = (bytes_transferred as f64 / tot as f64) * 100.0; pb.set_style(
print!("\r \x1b[1;33mProgress:\x1b[0m {:>3.0}% ({}/{} bytes)", percent, bytes_transferred, tot); ProgressStyle::default_bar()
} else { .template(" [{elapsed_precise}] [{bar:35.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, ETA {eta})")
print!("\r \x1b[1;33mProgress:\x1b[0m {} bytes transferred", bytes_transferred); .unwrap()
.progress_chars("#>-"),
);
pb
} }
let _ = io::Write::flush(&mut io::stdout()); _ => {
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, .. } => {
pb.set_position(bytes_transferred);
} }
ProgressEvent::Completed { total_bytes } => { ProgressEvent::Completed { total_bytes } => {
print!("\r \x1b[1;33mProgress:\x1b[0m 100% ({} bytes) ", total_bytes); pb.set_position(total_bytes);
let _ = io::Write::flush(&mut io::stdout()); pb.finish_and_clear();
} }
_ => {} _ => {}
}) })