diff --git a/crates/soar-cli/src/apply.rs b/crates/soar-cli/src/apply.rs index f68b0e3d..50ed1564 100644 --- a/crates/soar-cli/src/apply.rs +++ b/crates/soar-cli/src/apply.rs @@ -12,6 +12,7 @@ use tracing::{info, warn}; use crate::{ json_output::{self, ApplyDiffJson}, + progress::create_wait_job, utils::{display_settings, icon_or, json_enabled, Colored, Icons}, }; @@ -37,7 +38,14 @@ pub async fn apply_packages( info!("Loaded {} package declaration(s)", resolved.len()); - let diff = apply::compute_diff(ctx, &resolved, prune).await?; + // Declarations backed by a remote source are resolved over the network here. + let spinner = create_wait_job(&format!( + "resolving {} package declaration(s)", + resolved.len() + )); + let resolution = apply::compute_diff(ctx, &resolved, prune).await; + spinner.finish_and_clear(); + let diff = resolution?; if answers_with_diff { json_output::emit(&ApplyDiffJson::new(&diff)); diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index 88ca50d4..ac831603 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -7,9 +7,12 @@ use tabled::{ }; use tracing::{debug, error, info, warn}; -use crate::utils::{ - ask_target_action, display_settings, icon_or, select_package_interactively, - select_package_interactively_with_installed, Colored, Icons, +use crate::{ + progress::create_wait_job, + utils::{ + ask_target_action, display_settings, icon_or, select_package_interactively, + select_package_interactively_with_installed, Colored, Icons, + }, }; #[allow(clippy::too_many_arguments)] @@ -59,7 +62,11 @@ pub async fn install_packages( return install_with_show(ctx, packages, &options, yes, force, ask, no_notes).await; } - let results = install::resolve_packages(ctx, packages, &options).await?; + // A URL or OCI reference is resolved against its remote here. + let spinner = create_wait_job("resolving packages"); + let resolution = install::resolve_packages(ctx, packages, &options).await; + spinner.finish_and_clear(); + let results = resolution?; let mut install_targets = Vec::new(); for result in results { @@ -148,9 +155,11 @@ async fn install_with_show( if soar_core::package::local::LocalPackage::is_local(package) || soar_core::package::url::UrlPackage::is_remote(package) { - let results = - install::resolve_packages(ctx, std::slice::from_ref(package), options).await?; - for result in results { + let spinner = create_wait_job("resolving packages"); + let resolution = + install::resolve_packages(ctx, std::slice::from_ref(package), options).await; + spinner.finish_and_clear(); + for result in resolution? { match result { ResolveResult::Resolved(targets) => install_targets.extend(targets), ResolveResult::AlreadyInstalled { diff --git a/crates/soar-cli/src/progress.rs b/crates/soar-cli/src/progress.rs index 732a5446..6d0f2f4f 100644 --- a/crates/soar-cli/src/progress.rs +++ b/crates/soar-cli/src/progress.rs @@ -64,6 +64,12 @@ fn spinner_style() -> ProgressStyle { ProgressStyle::with_template("{spinner:.cyan} {msg}").unwrap() } +/// Spinner that also shows how long the wait has lasted, for stages that block on +/// a remote and have nothing else to report. +fn waiting_style() -> ProgressStyle { + ProgressStyle::with_template("{spinner:.cyan} {msg} {elapsed:.dim}").unwrap() +} + /// Create a download progress bar with a progress bar, bytes, and ETA. pub fn create_download_job(prefix: &str) -> ProgressBar { let pb = if progress_enabled() { @@ -77,6 +83,23 @@ pub fn create_download_job(prefix: &str) -> ProgressBar { pb } +/// Create a spinner job for a stage that blocks on a remote, showing elapsed time. +/// +/// Unlike [`create_spinner_job`] this ignores the `spinners` display setting: it is the +/// only feedback such a stage has, and without it the run looks stuck. +pub fn create_wait_job(message: &str) -> ProgressBar { + // Left out of MULTI entirely: adding a bar to it overrides the hidden draw + // target, so a hidden bar added to it still draws. + if !progress_enabled() { + return ProgressBar::hidden(); + } + let pb = MULTI.add(ProgressBar::new_spinner()); + pb.set_style(waiting_style()); + pb.set_message(message.to_string()); + pb.enable_steady_tick(Duration::from_millis(100)); + pb +} + /// Create a spinner job. pub fn create_spinner_job(message: &str) -> ProgressBar { let pb = if progress_enabled() && display_settings().spinners() { @@ -93,17 +116,25 @@ pub fn create_spinner_job(message: &str) -> ProgressBar { /// Handle download progress events and update a progress bar. pub fn handle_download_progress(state: Progress, pb: &ProgressBar) { match state { + Progress::Preparing => { + pb.set_style(waiting_style()); + pb.set_message("connecting"); + } Progress::Starting { total, } => { + pb.reset(); pb.set_length(total); + pb.set_style(download_style()); } Progress::Resuming { current, total, } => { + pb.reset(); pb.set_length(total); pb.set_position(current); + pb.set_style(download_style()); } Progress::Chunk { current, .. @@ -119,6 +150,18 @@ pub fn handle_download_progress(state: Progress, pb: &ProgressBar) { } } +/// Create the bar an operation's download uses, from the wait for the remote through +/// the transfer itself. It starts in the waiting state, which is where every +/// download begins. +fn create_download_bar(pkg_name: &str) -> ProgressBar { + let pb = MULTI.add(ProgressBar::new(0)); + pb.set_style(waiting_style()); + pb.set_prefix(colored_prefix(pkg_name)); + pb.set_message(format!("{pkg_name}: connecting")); + pb.enable_steady_tick(Duration::from_millis(100)); + pb +} + /// Create a spinner-style progress bar for an operation. fn create_op_spinner(msg: &str) -> ProgressBar { let pb = if progress_enabled() && display_settings().spinners() { @@ -166,18 +209,40 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { while let Ok(event) = receiver.recv() { match event { // ── Download lifecycle ────────────────────────────────── + // The bar is created here so the wait for a slow remote is + // visible, and reused once the transfer starts. + SoarEvent::DownloadPreparing { + op_id, + pkg_name, + .. + } => { + let is_new = !jobs.contains_key(&op_id); + let pb = jobs + .entry(op_id) + .or_insert_with(|| create_download_bar(&pkg_name)); + pb.set_style(waiting_style()); + pb.set_message(format!("{pkg_name}: connecting")); + if is_new { + reposition_batch!(batch_job, batch_msg); + } + } SoarEvent::DownloadStarting { op_id, pkg_name, total, .. } => { - let pb = MULTI.add(ProgressBar::new(total)); + let is_new = !jobs.contains_key(&op_id); + let pb = jobs + .entry(op_id) + .or_insert_with(|| create_download_bar(&pkg_name)); + pb.reset(); + pb.set_length(total); + // Set last: a draw between the two would paint a full bar at 0/0. pb.set_style(download_style()); - pb.set_prefix(colored_prefix(&pkg_name)); - pb.enable_steady_tick(Duration::from_millis(100)); - jobs.insert(op_id, pb); - reposition_batch!(batch_job, batch_msg); + if is_new { + reposition_batch!(batch_job, batch_msg); + } } SoarEvent::DownloadResuming { op_id, @@ -187,15 +252,13 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { .. } => { let is_new = !jobs.contains_key(&op_id); - let pb = jobs.entry(op_id).or_insert_with(|| { - let pb = MULTI.add(ProgressBar::new(0)); - pb.set_style(download_style()); - pb.set_prefix(colored_prefix(&pkg_name)); - pb.enable_steady_tick(Duration::from_millis(100)); - pb - }); + let pb = jobs + .entry(op_id) + .or_insert_with(|| create_download_bar(&pkg_name)); + pb.reset(); pb.set_length(total); pb.set_position(current); + pb.set_style(download_style()); if is_new { reposition_batch!(batch_job, batch_msg); } @@ -220,10 +283,14 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { } } SoarEvent::DownloadRetry { - op_id, .. + op_id, + pkg_name, + .. } => { if let Some(pb) = jobs.get(&op_id) { + pb.set_style(waiting_style()); pb.set_position(0); + pb.set_message(format!("{pkg_name}: retrying")); } } SoarEvent::DownloadAborted { @@ -239,13 +306,8 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { .. } => { let is_new = !jobs.contains_key(&op_id); - jobs.entry(op_id).or_insert_with(|| { - let pb = MULTI.add(ProgressBar::new(0)); - pb.set_style(download_style()); - pb.set_prefix(colored_prefix(&pkg_name)); - pb.enable_steady_tick(Duration::from_millis(100)); - pb - }); + jobs.entry(op_id) + .or_insert_with(|| create_download_bar(&pkg_name)); if is_new { reposition_batch!(batch_job, batch_msg); } diff --git a/crates/soar-cli/src/update.rs b/crates/soar-cli/src/update.rs index cf17aae3..2ab9a9f6 100644 --- a/crates/soar-cli/src/update.rs +++ b/crates/soar-cli/src/update.rs @@ -9,6 +9,7 @@ use tracing::{error, info}; use crate::{ json_output::{self, Listing, UpdateJson}, + progress::create_wait_job, utils::{ask_target_action, display_settings, icon_or, json_enabled, Colored, Icons}, }; @@ -20,7 +21,11 @@ pub async fn update_packages( check: bool, no_verify: bool, ) -> SoarResult<()> { - let updates = update::check_updates(ctx, packages.as_deref()).await?; + // Packages installed from a remote source are checked over the network here. + let spinner = create_wait_job("checking for updates"); + let checked = update::check_updates(ctx, packages.as_deref()).await; + spinner.finish_and_clear(); + let updates = checked?; if check { return report_pending(&updates); diff --git a/crates/soar-dl/src/download.rs b/crates/soar-dl/src/download.rs index d0f953c8..a3242000 100644 --- a/crates/soar-dl/src/download.rs +++ b/crates/soar-dl/src/download.rs @@ -232,6 +232,10 @@ impl Download { pub fn execute(self) -> Result { debug!(url = self.url, "starting download"); + if let Some(ref cb) = self.on_progress { + cb(Progress::Preparing); + } + if self.output.as_deref() == Some("-") { trace!("output is stdout"); return self.download_to_stdout(); @@ -425,6 +429,10 @@ impl Download { debug!(offset = offset, "attempting to resume download"); } + if let Some(ref cb) = self.on_progress { + cb(Progress::Preparing); + } + let resp = Http::fetch(&self.url, resume_from, etag, self.ghcr_blob)?; let status = resp.status(); diff --git a/crates/soar-dl/src/oci.rs b/crates/soar-dl/src/oci.rs index 4225e122..c69e10cd 100644 --- a/crates/soar-dl/src/oci.rs +++ b/crates/soar-dl/src/oci.rs @@ -421,6 +421,10 @@ impl OciDownload { "starting OCI download" ); + if let Some(cb) = &self.on_progress { + cb(Progress::Preparing); + } + // If it's a blob digest, download directly if self.reference.tag.starts_with("sha256:") { trace!("tag is digest, downloading blob directly"); diff --git a/crates/soar-dl/src/types.rs b/crates/soar-dl/src/types.rs index 8c10ca92..4bcebf16 100644 --- a/crates/soar-dl/src/types.rs +++ b/crates/soar-dl/src/types.rs @@ -1,8 +1,12 @@ use serde::{Deserialize, Serialize}; /// Download progress events +/// +/// `Preparing` is the wait on the remote: the request is out, but no byte has +/// arrived and the size is still unknown. #[derive(Debug, Clone, Copy)] pub enum Progress { + Preparing, Starting { total: u64 }, Resuming { current: u64, total: u64 }, Chunk { current: u64, total: u64 }, diff --git a/crates/soar-dl/src/zsync.rs b/crates/soar-dl/src/zsync.rs index 1f408c83..c803517e 100644 --- a/crates/soar-dl/src/zsync.rs +++ b/crates/soar-dl/src/zsync.rs @@ -114,6 +114,10 @@ pub fn download( where F: Fn(Progress) + Send + Sync + 'static, { + if let Some(ref callback) = on_progress { + callback(Progress::Preparing); + } + let mut assembly = ZsyncAssembly::from_url(url, output) .map_err(|e| DownloadError::Zsync(format!("reading zsync control file: {e}")))?; diff --git a/crates/soar-events/src/event.rs b/crates/soar-events/src/event.rs index 3f0162c7..863f0359 100644 --- a/crates/soar-events/src/event.rs +++ b/crates/soar-events/src/event.rs @@ -4,6 +4,11 @@ use crate::OperationId; #[derive(Debug, Clone, serde::Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SoarEvent { + /// Contacting the remote. No bytes have moved yet and the size is unknown. + DownloadPreparing { + op_id: OperationId, + pkg_name: String, + }, /// Download is starting. DownloadStarting { op_id: OperationId, diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index 5e8a32fb..8c6e89f9 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -748,6 +748,15 @@ pub async fn perform_installation( let completed = Arc::new(AtomicU32::new(0)); let failed_count = Arc::new(AtomicU32::new(0)); + // Without this the count only appears once the first package is done. + if total > 1 { + ctx.events().emit(SoarEvent::BatchProgress { + completed: 0, + total, + failed: 0, + }); + } + let mut handles = Vec::new(); for target in targets { diff --git a/crates/soar-operations/src/progress.rs b/crates/soar-operations/src/progress.rs index f762f850..88adc9a4 100644 --- a/crates/soar-operations/src/progress.rs +++ b/crates/soar-operations/src/progress.rs @@ -17,6 +17,12 @@ pub fn create_progress_bridge( ) -> Arc { Arc::new(move |progress| { let event = match progress { + Progress::Preparing => { + SoarEvent::DownloadPreparing { + op_id, + pkg_name: pkg_name.clone(), + } + } Progress::Starting { total, } => { @@ -108,6 +114,7 @@ mod tests { let bridge = create_progress_bridge(events, 1, "pkg".into()); + bridge(Progress::Preparing); bridge(Progress::Starting { total: 1000, }); @@ -127,17 +134,27 @@ mod tests { bridge(Progress::Recovered); let events = collector.events(); - assert_eq!(events.len(), 7); + assert_eq!(events.len(), 8); + match &events[0] { + SoarEvent::DownloadPreparing { + op_id, + pkg_name, + } => { + assert_eq!(*op_id, 1); + assert_eq!(pkg_name, "pkg"); + } + other => panic!("expected DownloadPreparing, got {other:?}"), + } assert!(matches!( - &events[0], + &events[1], SoarEvent::DownloadStarting { total: 1000, .. } )); assert!(matches!( - &events[1], + &events[2], SoarEvent::DownloadResuming { current: 500, total: 1000, @@ -145,7 +162,7 @@ mod tests { } )); assert!(matches!( - &events[2], + &events[3], SoarEvent::DownloadProgress { current: 750, total: 1000, @@ -153,14 +170,14 @@ mod tests { } )); assert!(matches!( - &events[3], + &events[4], SoarEvent::DownloadComplete { total: 1000, .. } )); - assert!(matches!(&events[4], SoarEvent::DownloadRetry { .. })); - assert!(matches!(&events[5], SoarEvent::DownloadAborted { .. })); - assert!(matches!(&events[6], SoarEvent::DownloadRecovered { .. })); + assert!(matches!(&events[5], SoarEvent::DownloadRetry { .. })); + assert!(matches!(&events[6], SoarEvent::DownloadAborted { .. })); + assert!(matches!(&events[7], SoarEvent::DownloadRecovered { .. })); } }