diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-13 16:59:54 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-13 16:59:54 +0000 |
| commit | 9dd97603f5903184ed251ab3bd5920309286223f (patch) | |
| tree | 9fcd02e0db833dd025a133957030d7db0060fd13 /src | |
| parent | 40b439ae4d69b858274be51dd5af513c3b4f46f0 (diff) | |
refactor: hidden-to-visible MultiProgress pattern
Replace the broken SpinnerState approach (which checked should_expand at
task spawn time when all tasks spawn simultaneously) with a two-MultiProgress
pattern: a visible spinner shown immediately, and a hidden detail multi that
every relay task always adds bars to. A background timer reveals the detail
bars after 5s, printing a heading before switching the draw target.
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/client.rs | 173 |
1 files changed, 72 insertions, 101 deletions
diff --git a/src/lib/client.rs b/src/lib/client.rs index 583f01c..7c83e19 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs | |||
| @@ -16,10 +16,10 @@ use std::{ | |||
| 16 | fs::create_dir_all, | 16 | fs::create_dir_all, |
| 17 | path::Path, | 17 | path::Path, |
| 18 | sync::{ | 18 | sync::{ |
| 19 | Arc, Mutex, RwLock, | 19 | Arc, RwLock, |
| 20 | atomic::{AtomicU64, Ordering}, | 20 | atomic::{AtomicU64, Ordering}, |
| 21 | }, | 21 | }, |
| 22 | time::{Duration, Instant}, | 22 | time::Duration, |
| 23 | }; | 23 | }; |
| 24 | 24 | ||
| 25 | use anyhow::{Context, Result, anyhow, bail}; | 25 | use anyhow::{Context, Result, anyhow, bail}; |
| @@ -69,60 +69,7 @@ pub fn is_verbose() -> bool { | |||
| 69 | std::env::var("NGIT_VERBOSE").is_ok() | 69 | std::env::var("NGIT_VERBOSE").is_ok() |
| 70 | } | 70 | } |
| 71 | 71 | ||
| 72 | const SPINNER_EXPAND_DELAY_SECS: u64 = 5; | 72 | const SPINNER_EXPAND_DELAY_MS: u64 = 5000; |
| 73 | |||
| 74 | struct SpinnerState { | ||
| 75 | spinner: ProgressBar, | ||
| 76 | start_time: Instant, | ||
| 77 | expanded_multi: Option<MultiProgress>, | ||
| 78 | } | ||
| 79 | |||
| 80 | impl SpinnerState { | ||
| 81 | fn new() -> Self { | ||
| 82 | let multi_progress = MultiProgress::new(); | ||
| 83 | let spinner = multi_progress.add( | ||
| 84 | ProgressBar::new_spinner() | ||
| 85 | .with_style( | ||
| 86 | ProgressStyle::with_template("{spinner} {msg}") | ||
| 87 | .unwrap() | ||
| 88 | .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"), | ||
| 89 | ) | ||
| 90 | .with_message("Checking relays and git servers..."), | ||
| 91 | ); | ||
| 92 | spinner.enable_steady_tick(Duration::from_millis(100)); | ||
| 93 | Self { | ||
| 94 | spinner, | ||
| 95 | start_time: Instant::now(), | ||
| 96 | expanded_multi: None, | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | fn should_expand(&self) -> bool { | ||
| 101 | self.expanded_multi.is_none() | ||
| 102 | && self.start_time.elapsed().as_secs() >= SPINNER_EXPAND_DELAY_SECS | ||
| 103 | } | ||
| 104 | |||
| 105 | fn expand(&mut self) -> &MultiProgress { | ||
| 106 | if self.expanded_multi.is_none() { | ||
| 107 | self.spinner.finish_and_clear(); | ||
| 108 | self.expanded_multi = Some(MultiProgress::new()); | ||
| 109 | } | ||
| 110 | self.expanded_multi.as_ref().unwrap() | ||
| 111 | } | ||
| 112 | |||
| 113 | fn finish(&self, has_errors: bool) { | ||
| 114 | if has_errors { | ||
| 115 | if let Some(ref multi) = self.expanded_multi { | ||
| 116 | let _ = multi.clear(); | ||
| 117 | } | ||
| 118 | } else { | ||
| 119 | self.spinner.finish_and_clear(); | ||
| 120 | if let Some(ref multi) = self.expanded_multi { | ||
| 121 | let _ = multi.clear(); | ||
| 122 | } | ||
| 123 | } | ||
| 124 | } | ||
| 125 | } | ||
| 126 | 73 | ||
| 127 | #[allow(clippy::struct_field_names)] | 74 | #[allow(clippy::struct_field_names)] |
| 128 | pub struct Client { | 75 | pub struct Client { |
| @@ -430,12 +377,54 @@ impl Connect for Client { | |||
| 430 | .await?; | 377 | .await?; |
| 431 | 378 | ||
| 432 | let verbose = is_verbose(); | 379 | let verbose = is_verbose(); |
| 433 | let spinner_state = if !verbose { | 380 | let is_test = std::env::var("NGITTEST").is_ok(); |
| 434 | Some(Arc::new(Mutex::new(SpinnerState::new()))) | 381 | |
| 382 | // Set up the two-MultiProgress pattern: | ||
| 383 | // 1. A spinner MultiProgress shown immediately (concise mode only) | ||
| 384 | // 2. A detail MultiProgress that starts hidden and becomes visible after a delay | ||
| 385 | let spinner_multi = if !verbose && !is_test { | ||
| 386 | let m = MultiProgress::new(); | ||
| 387 | let spinner = m.add( | ||
| 388 | ProgressBar::new_spinner() | ||
| 389 | .with_style( | ||
| 390 | ProgressStyle::with_template("{spinner} {msg}") | ||
| 391 | .unwrap() | ||
| 392 | .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"), | ||
| 393 | ) | ||
| 394 | .with_message("Checking relays and git servers..."), | ||
| 395 | ); | ||
| 396 | spinner.enable_steady_tick(Duration::from_millis(100)); | ||
| 397 | Some((m, spinner)) | ||
| 398 | } else { | ||
| 399 | None | ||
| 400 | }; | ||
| 401 | |||
| 402 | let progress_reporter = if is_test { | ||
| 403 | MultiProgress::with_draw_target(ProgressDrawTarget::hidden()) | ||
| 404 | } else if verbose { | ||
| 405 | MultiProgress::new() | ||
| 406 | } else { | ||
| 407 | MultiProgress::with_draw_target(ProgressDrawTarget::hidden()) | ||
| 408 | }; | ||
| 409 | |||
| 410 | // Spawn a background timer that transitions from spinner to detail view | ||
| 411 | let detail_multi_for_timer = progress_reporter.clone(); | ||
| 412 | let spinner_for_timer = spinner_multi.as_ref().map(|(_, s)| s.clone()); | ||
| 413 | let timer_handle = if !verbose && !is_test { | ||
| 414 | let handle = tokio::spawn(async move { | ||
| 415 | tokio::time::sleep(Duration::from_millis(SPINNER_EXPAND_DELAY_MS)).await; | ||
| 416 | // Transition: finish spinner, show heading, reveal detail bars | ||
| 417 | if let Some(spinner) = spinner_for_timer { | ||
| 418 | spinner.finish_and_clear(); | ||
| 419 | } | ||
| 420 | eprintln!("fetching updates..."); | ||
| 421 | detail_multi_for_timer | ||
| 422 | .set_draw_target(ProgressDrawTarget::stderr()); | ||
| 423 | }); | ||
| 424 | Some(handle) | ||
| 435 | } else { | 425 | } else { |
| 436 | None | 426 | None |
| 437 | }; | 427 | }; |
| 438 | let progress_reporter = MultiProgress::new(); | ||
| 439 | 428 | ||
| 440 | let success_count = Arc::new(AtomicU64::new(0)); | 429 | let success_count = Arc::new(AtomicU64::new(0)); |
| 441 | let current_timeout = Arc::new(AtomicU64::new(long_timeout())); | 430 | let current_timeout = Arc::new(AtomicU64::new(long_timeout())); |
| @@ -471,7 +460,6 @@ impl Connect for Client { | |||
| 471 | let success_count_for_loop = success_count.clone(); | 460 | let success_count_for_loop = success_count.clone(); |
| 472 | let current_timeout_for_loop = current_timeout.clone(); | 461 | let current_timeout_for_loop = current_timeout.clone(); |
| 473 | let total_relays = relays.len() as u64; | 462 | let total_relays = relays.len() as u64; |
| 474 | let spinner_state_clone = spinner_state.clone(); | ||
| 475 | 463 | ||
| 476 | let futures: Vec<_> = relays | 464 | let futures: Vec<_> = relays |
| 477 | .iter() | 465 | .iter() |
| @@ -506,8 +494,6 @@ impl Connect for Client { | |||
| 506 | let current_timeout_clone = current_timeout_for_loop.clone(); | 494 | let current_timeout_clone = current_timeout_for_loop.clone(); |
| 507 | let progress_reporter_clone = progress_reporter.clone(); | 495 | let progress_reporter_clone = progress_reporter.clone(); |
| 508 | let total_relays_clone = total_relays; | 496 | let total_relays_clone = total_relays; |
| 509 | let spinner_state_for_task = spinner_state_clone.clone(); | ||
| 510 | let verbose_for_task = verbose; | ||
| 511 | async move { | 497 | async move { |
| 512 | let relay_column_width = request.relay_column_width; | 498 | let relay_column_width = request.relay_column_width; |
| 513 | 499 | ||
| @@ -516,43 +502,23 @@ impl Connect for Client { | |||
| 516 | .clone() | 502 | .clone() |
| 517 | .context("fetch_all_from_relay called without a relay")?; | 503 | .context("fetch_all_from_relay called without a relay")?; |
| 518 | 504 | ||
| 519 | let pb = if verbose_for_task { | 505 | // Always create a real progress bar added to the detail |
| 520 | let pb = progress_reporter_clone.add( | 506 | // multi. In test mode the multi has a hidden draw target |
| 521 | ProgressBar::new(1) | 507 | // so nothing is displayed. In concise mode the multi |
| 522 | .with_prefix( | 508 | // starts hidden and the background timer reveals it. |
| 523 | format!( | 509 | let pb = progress_reporter_clone.add( |
| 524 | "{: <relay_column_width$} connecting", | 510 | ProgressBar::new(1) |
| 525 | &relay_url | 511 | .with_prefix( |
| 526 | ) | 512 | format!( |
| 527 | .to_string(), | 513 | "{: <relay_column_width$} connecting", |
| 514 | &relay_url | ||
| 528 | ) | 515 | ) |
| 529 | .with_style(pb_style(current_timeout_clone.clone())?), | 516 | .to_string(), |
| 530 | ); | 517 | ) |
| 531 | pb.enable_steady_tick(Duration::from_millis(300)); | 518 | .with_style(pb_style(current_timeout_clone.clone())?), |
| 532 | Some(pb) | 519 | ); |
| 533 | } else if let Some(ref state) = spinner_state_for_task { | 520 | pb.enable_steady_tick(Duration::from_millis(300)); |
| 534 | let mut state = state.lock().unwrap(); | 521 | let pb = Some(pb); |
| 535 | if state.should_expand() { | ||
| 536 | let multi = state.expand().clone(); | ||
| 537 | let pb = multi.add( | ||
| 538 | ProgressBar::new(1) | ||
| 539 | .with_prefix( | ||
| 540 | format!( | ||
| 541 | "{: <relay_column_width$} connecting", | ||
| 542 | &relay_url | ||
| 543 | ) | ||
| 544 | .to_string(), | ||
| 545 | ) | ||
| 546 | .with_style(pb_style(current_timeout_clone.clone())?), | ||
| 547 | ); | ||
| 548 | pb.enable_steady_tick(Duration::from_millis(300)); | ||
| 549 | Some(pb) | ||
| 550 | } else { | ||
| 551 | None | ||
| 552 | } | ||
| 553 | } else { | ||
| 554 | None | ||
| 555 | }; | ||
| 556 | 522 | ||
| 557 | fn update_progress_bar_with_error( | 523 | fn update_progress_bar_with_error( |
| 558 | relay_column_width: usize, | 524 | relay_column_width: usize, |
| @@ -689,9 +655,14 @@ impl Connect for Client { | |||
| 689 | }; | 655 | }; |
| 690 | } | 656 | } |
| 691 | 657 | ||
| 692 | if let Some(ref state) = spinner_state { | 658 | // Cancel the background timer if it hasn't fired yet, and clean up |
| 693 | let has_errors = relay_reports.iter().any(Result::is_err); | 659 | // the spinner. If the timer already fired, the abort is a no-op. |
| 694 | state.lock().unwrap().finish(has_errors); | 660 | if let Some(handle) = timer_handle { |
| 661 | handle.abort(); | ||
| 662 | } | ||
| 663 | // Clear the spinner (no-op if timer already cleared it) | ||
| 664 | if let Some((_, spinner)) = &spinner_multi { | ||
| 665 | spinner.finish_and_clear(); | ||
| 695 | } | 666 | } |
| 696 | 667 | ||
| 697 | Ok((relay_reports, progress_reporter)) | 668 | Ok((relay_reports, progress_reporter)) |