diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-13 10:51:25 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-13 10:51:25 +0000 |
| commit | 40b439ae4d69b858274be51dd5af513c3b4f46f0 (patch) | |
| tree | 64beb8589b8a2da5aee7aecf8dc9564e21d676d0 /src/lib/client.rs | |
| parent | cfd8cc19b6a81ad78bc30d5b21cefe21d574d09e (diff) | |
feat: add spinner for git fetch in non-verbose mode
Shows a progress spinner when fetching from git remotes in non-verbose mode.
Suppresses git fetch output and listing messages when not in verbose mode.
Uses NGITTEST environment variable for test timeouts.
Diffstat (limited to 'src/lib/client.rs')
| -rw-r--r-- | src/lib/client.rs | 126 |
1 files changed, 102 insertions, 24 deletions
diff --git a/src/lib/client.rs b/src/lib/client.rs index 89fcaf7..583f01c 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, RwLock, | 19 | Arc, Mutex, RwLock, |
| 20 | atomic::{AtomicU64, Ordering}, | 20 | atomic::{AtomicU64, Ordering}, |
| 21 | }, | 21 | }, |
| 22 | time::Duration, | 22 | time::{Duration, Instant}, |
| 23 | }; | 23 | }; |
| 24 | 24 | ||
| 25 | use anyhow::{Context, Result, anyhow, bail}; | 25 | use anyhow::{Context, Result, anyhow, bail}; |
| @@ -65,6 +65,65 @@ use crate::{ | |||
| 65 | repo_state::RepoState, | 65 | repo_state::RepoState, |
| 66 | }; | 66 | }; |
| 67 | 67 | ||
| 68 | pub fn is_verbose() -> bool { | ||
| 69 | std::env::var("NGIT_VERBOSE").is_ok() | ||
| 70 | } | ||
| 71 | |||
| 72 | const SPINNER_EXPAND_DELAY_SECS: u64 = 5; | ||
| 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 | |||
| 68 | #[allow(clippy::struct_field_names)] | 127 | #[allow(clippy::struct_field_names)] |
| 69 | pub struct Client { | 128 | pub struct Client { |
| 70 | client: nostr_sdk::Client, | 129 | client: nostr_sdk::Client, |
| @@ -370,14 +429,15 @@ impl Connect for Client { | |||
| 370 | ) | 429 | ) |
| 371 | .await?; | 430 | .await?; |
| 372 | 431 | ||
| 432 | let verbose = is_verbose(); | ||
| 433 | let spinner_state = if !verbose { | ||
| 434 | Some(Arc::new(Mutex::new(SpinnerState::new()))) | ||
| 435 | } else { | ||
| 436 | None | ||
| 437 | }; | ||
| 373 | let progress_reporter = MultiProgress::new(); | 438 | let progress_reporter = MultiProgress::new(); |
| 374 | 439 | ||
| 375 | // Track successful relays for adaptive timeout (switch to SHORT when | ||
| 376 | // SUCCESS_THRESHOLD succeed) | ||
| 377 | let success_count = Arc::new(AtomicU64::new(0)); | 440 | let success_count = Arc::new(AtomicU64::new(0)); |
| 378 | |||
| 379 | // Track current timeout value for progress bar display (starts at LONG, | ||
| 380 | // switches to SHORT) | ||
| 381 | let current_timeout = Arc::new(AtomicU64::new(long_timeout())); | 441 | let current_timeout = Arc::new(AtomicU64::new(long_timeout())); |
| 382 | 442 | ||
| 383 | let mut processed_relays = HashSet::new(); | 443 | let mut processed_relays = HashSet::new(); |
| @@ -388,7 +448,6 @@ impl Connect for Client { | |||
| 388 | let relays = request | 448 | let relays = request |
| 389 | .repo_relays | 449 | .repo_relays |
| 390 | .union(&request.user_relays_for_profiles) | 450 | .union(&request.user_relays_for_profiles) |
| 391 | // don't look for events on blaster | ||
| 392 | .filter(|&r| !r.as_str().contains("nostr.mutinywallet.com")) | 451 | .filter(|&r| !r.as_str().contains("nostr.mutinywallet.com")) |
| 393 | .cloned() | 452 | .cloned() |
| 394 | .collect::<HashSet<RelayUrl>>() | 453 | .collect::<HashSet<RelayUrl>>() |
| @@ -412,12 +471,12 @@ impl Connect for Client { | |||
| 412 | let success_count_for_loop = success_count.clone(); | 471 | let success_count_for_loop = success_count.clone(); |
| 413 | let current_timeout_for_loop = current_timeout.clone(); | 472 | let current_timeout_for_loop = current_timeout.clone(); |
| 414 | let total_relays = relays.len() as u64; | 473 | let total_relays = relays.len() as u64; |
| 474 | let spinner_state_clone = spinner_state.clone(); | ||
| 415 | 475 | ||
| 416 | let futures: Vec<_> = relays | 476 | let futures: Vec<_> = relays |
| 417 | .iter() | 477 | .iter() |
| 418 | .map(|r| { | 478 | .map(|r| { |
| 419 | if profile_relays_only.contains(r) { | 479 | if profile_relays_only.contains(r) { |
| 420 | // if relay isn't a repo relay, just filter for user profile | ||
| 421 | FetchRequest { | 480 | FetchRequest { |
| 422 | selected_relay: Some(r.to_owned()), | 481 | selected_relay: Some(r.to_owned()), |
| 423 | repo_coordinates_without_relays: vec![], | 482 | repo_coordinates_without_relays: vec![], |
| @@ -447,6 +506,8 @@ impl Connect for Client { | |||
| 447 | let current_timeout_clone = current_timeout_for_loop.clone(); | 506 | let current_timeout_clone = current_timeout_for_loop.clone(); |
| 448 | let progress_reporter_clone = progress_reporter.clone(); | 507 | let progress_reporter_clone = progress_reporter.clone(); |
| 449 | let total_relays_clone = total_relays; | 508 | let total_relays_clone = total_relays; |
| 509 | let spinner_state_for_task = spinner_state_clone.clone(); | ||
| 510 | let verbose_for_task = verbose; | ||
| 450 | async move { | 511 | async move { |
| 451 | let relay_column_width = request.relay_column_width; | 512 | let relay_column_width = request.relay_column_width; |
| 452 | 513 | ||
| @@ -455,7 +516,7 @@ impl Connect for Client { | |||
| 455 | .clone() | 516 | .clone() |
| 456 | .context("fetch_all_from_relay called without a relay")?; | 517 | .context("fetch_all_from_relay called without a relay")?; |
| 457 | 518 | ||
| 458 | let pb = if std::env::var("NGITTEST").is_err() { | 519 | let pb = if verbose_for_task { |
| 459 | let pb = progress_reporter_clone.add( | 520 | let pb = progress_reporter_clone.add( |
| 460 | ProgressBar::new(1) | 521 | ProgressBar::new(1) |
| 461 | .with_prefix( | 522 | .with_prefix( |
| @@ -469,6 +530,26 @@ impl Connect for Client { | |||
| 469 | ); | 530 | ); |
| 470 | pb.enable_steady_tick(Duration::from_millis(300)); | 531 | pb.enable_steady_tick(Duration::from_millis(300)); |
| 471 | Some(pb) | 532 | Some(pb) |
| 533 | } else if let Some(ref state) = spinner_state_for_task { | ||
| 534 | let mut state = state.lock().unwrap(); | ||
| 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 | } | ||
| 472 | } else { | 553 | } else { |
| 473 | None | 554 | None |
| 474 | }; | 555 | }; |
| @@ -508,36 +589,27 @@ impl Connect for Client { | |||
| 508 | bail!("{reason}"); | 589 | bail!("{reason}"); |
| 509 | } | 590 | } |
| 510 | 591 | ||
| 511 | // Adaptive timeout using tokio::select! | ||
| 512 | // Start the fetch operation once and race it against an adaptive timeout | ||
| 513 | let pb_clone = pb.clone(); | 592 | let pb_clone = pb.clone(); |
| 514 | let fetch_future = self.fetch_all_from_relay(git_repo_path, request, &pb_clone); | 593 | let fetch_future = self.fetch_all_from_relay(git_repo_path, request, &pb_clone); |
| 515 | tokio::pin!(fetch_future); | 594 | tokio::pin!(fetch_future); |
| 516 | 595 | ||
| 517 | // Create an adaptive timeout that switches from long to short | ||
| 518 | // when SUCCESS_THRESHOLD of relays succeed | ||
| 519 | let timeout_future = async { | 596 | let timeout_future = async { |
| 520 | // Poll for timeout or SUCCESS_THRESHOLD success threshold | ||
| 521 | let check_interval = Duration::from_millis(100); | 597 | let check_interval = Duration::from_millis(100); |
| 522 | let long_timeout_end = tokio::time::Instant::now() + Duration::from_secs(long_timeout()); | 598 | let long_timeout_end = tokio::time::Instant::now() + Duration::from_secs(long_timeout()); |
| 523 | 599 | ||
| 524 | loop { | 600 | loop { |
| 525 | // Check if SUCCESS_THRESHOLD of relays have succeeded | ||
| 526 | let current_success_count = success_count_clone.load(Ordering::Relaxed); | 601 | let current_success_count = success_count_clone.load(Ordering::Relaxed); |
| 527 | let threshold = (total_relays_clone as f64 * SUCCESS_THRESHOLD).ceil() as u64; | 602 | let threshold = (total_relays_clone as f64 * SUCCESS_THRESHOLD).ceil() as u64; |
| 528 | 603 | ||
| 529 | if current_success_count >= threshold { | 604 | if current_success_count >= threshold { |
| 530 | // SUCCESS_THRESHOLD reached, switch to short timeout | ||
| 531 | tokio::time::sleep(Duration::from_secs(short_timeout())).await; | 605 | tokio::time::sleep(Duration::from_secs(short_timeout())).await; |
| 532 | return "short"; | 606 | return "short"; |
| 533 | } | 607 | } |
| 534 | 608 | ||
| 535 | // Check if long timeout has expired | ||
| 536 | if tokio::time::Instant::now() >= long_timeout_end { | 609 | if tokio::time::Instant::now() >= long_timeout_end { |
| 537 | return "long"; | 610 | return "long"; |
| 538 | } | 611 | } |
| 539 | 612 | ||
| 540 | // Sleep briefly before checking again | ||
| 541 | tokio::time::sleep(check_interval).await; | 613 | tokio::time::sleep(check_interval).await; |
| 542 | } | 614 | } |
| 543 | }; | 615 | }; |
| @@ -546,11 +618,9 @@ impl Connect for Client { | |||
| 546 | let result = tokio::select! { | 618 | let result = tokio::select! { |
| 547 | result = &mut fetch_future => { | 619 | result = &mut fetch_future => { |
| 548 | if result.is_ok() { | 620 | if result.is_ok() { |
| 549 | // Increment success count | ||
| 550 | let new_count = success_count_clone.fetch_add(1, Ordering::Relaxed) + 1; | 621 | let new_count = success_count_clone.fetch_add(1, Ordering::Relaxed) + 1; |
| 551 | let threshold = (total_relays_clone as f64 * SUCCESS_THRESHOLD).ceil() as u64; | 622 | let threshold = (total_relays_clone as f64 * SUCCESS_THRESHOLD).ceil() as u64; |
| 552 | 623 | ||
| 553 | // If we've reached SUCCESS_THRESHOLD, update timeout display | ||
| 554 | if new_count >= threshold { | 624 | if new_count >= threshold { |
| 555 | current_timeout_clone.store(short_timeout(), Ordering::Relaxed); | 625 | current_timeout_clone.store(short_timeout(), Ordering::Relaxed); |
| 556 | } | 626 | } |
| @@ -565,7 +635,6 @@ impl Connect for Client { | |||
| 565 | 635 | ||
| 566 | match result { | 636 | match result { |
| 567 | Err(error) => { | 637 | Err(error) => { |
| 568 | // Check error for timeout/connection issues and add to skip list | ||
| 569 | if error.to_string().contains("connection timeout") || error.to_string().contains("timeout after") { | 638 | if error.to_string().contains("connection timeout") || error.to_string().contains("timeout after") { |
| 570 | self.skip_relay_for_session(relay_url.clone(), error.to_string()); | 639 | self.skip_relay_for_session(relay_url.clone(), error.to_string()); |
| 571 | } | 640 | } |
| @@ -619,6 +688,12 @@ impl Connect for Client { | |||
| 619 | set | 688 | set |
| 620 | }; | 689 | }; |
| 621 | } | 690 | } |
| 691 | |||
| 692 | if let Some(ref state) = spinner_state { | ||
| 693 | let has_errors = relay_reports.iter().any(Result::is_err); | ||
| 694 | state.lock().unwrap().finish(has_errors); | ||
| 695 | } | ||
| 696 | |||
| 622 | Ok((relay_reports, progress_reporter)) | 697 | Ok((relay_reports, progress_reporter)) |
| 623 | } | 698 | } |
| 624 | 699 | ||
| @@ -2084,8 +2159,11 @@ pub async fn fetching_with_report( | |||
| 2084 | #[cfg(not(test))] client: &Client, | 2159 | #[cfg(not(test))] client: &Client, |
| 2085 | trusted_maintainer_coordinate: &Nip19Coordinate, | 2160 | trusted_maintainer_coordinate: &Nip19Coordinate, |
| 2086 | ) -> Result<FetchReport> { | 2161 | ) -> Result<FetchReport> { |
| 2087 | let term = console::Term::stderr(); | 2162 | let verbose = is_verbose(); |
| 2088 | term.write_line("fetching updates...")?; | 2163 | if verbose { |
| 2164 | let term = console::Term::stderr(); | ||
| 2165 | term.write_line("fetching updates...")?; | ||
| 2166 | } | ||
| 2089 | let (relay_reports, progress_reporter) = client | 2167 | let (relay_reports, progress_reporter) = client |
| 2090 | .fetch_all( | 2168 | .fetch_all( |
| 2091 | Some(git_repo_path), | 2169 | Some(git_repo_path), |