upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-13 10:51:25 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-13 10:51:25 +0000
commit40b439ae4d69b858274be51dd5af513c3b4f46f0 (patch)
tree64beb8589b8a2da5aee7aecf8dc9564e21d676d0 /src/lib
parentcfd8cc19b6a81ad78bc30d5b21cefe21d574d09e (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')
-rw-r--r--src/lib/client.rs126
-rw-r--r--src/lib/list.rs110
2 files changed, 195 insertions, 41 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
25use anyhow::{Context, Result, anyhow, bail}; 25use anyhow::{Context, Result, anyhow, bail};
@@ -65,6 +65,65 @@ use crate::{
65 repo_state::RepoState, 65 repo_state::RepoState,
66}; 66};
67 67
68pub fn is_verbose() -> bool {
69 std::env::var("NGIT_VERBOSE").is_ok()
70}
71
72const SPINNER_EXPAND_DELAY_SECS: u64 = 5;
73
74struct SpinnerState {
75 spinner: ProgressBar,
76 start_time: Instant,
77 expanded_multi: Option<MultiProgress>,
78}
79
80impl 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)]
69pub struct Client { 128pub 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),
diff --git a/src/lib/list.rs b/src/lib/list.rs
index ce8737c..3b37b37 100644
--- a/src/lib/list.rs
+++ b/src/lib/list.rs
@@ -3,10 +3,10 @@ use std::{
3 path::PathBuf, 3 path::PathBuf,
4 str::FromStr, 4 str::FromStr,
5 sync::{ 5 sync::{
6 Arc, 6 Arc, Mutex,
7 atomic::{AtomicU64, Ordering}, 7 atomic::{AtomicU64, Ordering},
8 }, 8 },
9 time::Duration, 9 time::{Duration, Instant},
10}; 10};
11 11
12use anyhow::{Result, anyhow}; 12use anyhow::{Result, anyhow};
@@ -16,6 +16,7 @@ use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle};
16use nostr::hashes::sha1::Hash as Sha1Hash; 16use nostr::hashes::sha1::Hash as Sha1Hash;
17 17
18use crate::{ 18use crate::{
19 client::is_verbose,
19 git::{ 20 git::{
20 Repo, RepoActions, 21 Repo, RepoActions,
21 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol}, 22 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol},
@@ -28,6 +29,61 @@ use crate::{
28 }, 29 },
29}; 30};
30 31
32const SPINNER_EXPAND_DELAY_SECS: u64 = 5;
33
34struct GitSpinnerState {
35 spinner: ProgressBar,
36 start_time: Instant,
37 expanded_multi: Option<MultiProgress>,
38}
39
40impl GitSpinnerState {
41 fn new() -> Self {
42 let multi_progress = MultiProgress::new();
43 let spinner = multi_progress.add(
44 ProgressBar::new_spinner()
45 .with_style(
46 ProgressStyle::with_template("{spinner} {msg}")
47 .unwrap()
48 .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"),
49 )
50 .with_message("Checking git servers..."),
51 );
52 spinner.enable_steady_tick(Duration::from_millis(100));
53 Self {
54 spinner,
55 start_time: Instant::now(),
56 expanded_multi: None,
57 }
58 }
59
60 fn should_expand(&self) -> bool {
61 self.expanded_multi.is_none()
62 && self.start_time.elapsed().as_secs() >= SPINNER_EXPAND_DELAY_SECS
63 }
64
65 fn expand(&mut self) -> &MultiProgress {
66 if self.expanded_multi.is_none() {
67 self.spinner.finish_and_clear();
68 self.expanded_multi = Some(MultiProgress::new());
69 }
70 self.expanded_multi.as_ref().unwrap()
71 }
72
73 fn finish(&self, has_errors: bool) {
74 if has_errors {
75 if let Some(ref multi) = self.expanded_multi {
76 let _ = multi.clear();
77 }
78 } else {
79 self.spinner.finish_and_clear();
80 if let Some(ref multi) = self.expanded_multi {
81 let _ = multi.clear();
82 }
83 }
84 }
85}
86
31/// Sync issues identified for a single remote 87/// Sync issues identified for a single remote
32#[derive(Default, Debug, Clone)] 88#[derive(Default, Debug, Clone)]
33pub struct RemoteIssues { 89pub struct RemoteIssues {
@@ -111,18 +167,18 @@ pub async fn list_from_remotes(
111 return HashMap::new(); 167 return HashMap::new();
112 } 168 }
113 169
114 let progress_reporter = if std::env::var("NGITTEST").is_err() { 170 let verbose = is_verbose();
115 MultiProgress::new() 171 let spinner_state = if !verbose {
172 Some(Arc::new(Mutex::new(GitSpinnerState::new())))
116 } else { 173 } else {
117 MultiProgress::with_draw_target(indicatif::ProgressDrawTarget::hidden()) 174 None
118 }; 175 };
176 let progress_reporter = MultiProgress::new();
119 177
120 // Track successful servers for adaptive timeout
121 let success_count = Arc::new(AtomicU64::new(0)); 178 let success_count = Arc::new(AtomicU64::new(0));
122 let current_timeout = Arc::new(AtomicU64::new(git_server_long_timeout())); 179 let current_timeout = Arc::new(AtomicU64::new(git_server_long_timeout()));
123 let total_servers = git_servers.len() as u64; 180 let total_servers = git_servers.len() as u64;
124 181
125 // Calculate column width for alignment
126 let server_column_width = git_servers 182 let server_column_width = git_servers
127 .iter() 183 .iter()
128 .map(|s| get_short_git_server_name(s).chars().count()) 184 .map(|s| get_short_git_server_name(s).chars().count())
@@ -139,11 +195,13 @@ pub async fn list_from_remotes(
139 let current_timeout_clone = current_timeout.clone(); 195 let current_timeout_clone = current_timeout.clone();
140 let progress_reporter_clone = progress_reporter.clone(); 196 let progress_reporter_clone = progress_reporter.clone();
141 let decoded_nostr_url = decoded_nostr_url.clone(); 197 let decoded_nostr_url = decoded_nostr_url.clone();
198 let spinner_state_clone = spinner_state.clone();
199 let verbose_for_task = verbose;
142 200
143 async move { 201 async move {
144 let server_name = get_short_git_server_name(&url); 202 let server_name = get_short_git_server_name(&url);
145 203
146 let pb = if std::env::var("NGITTEST").is_err() { 204 let pb = if verbose_for_task {
147 match git_server_pb_style(current_timeout_clone.clone()) { 205 match git_server_pb_style(current_timeout_clone.clone()) {
148 Ok(style) => { 206 Ok(style) => {
149 let pb = progress_reporter_clone.add( 207 let pb = progress_reporter_clone.add(
@@ -164,6 +222,28 @@ pub async fn list_from_remotes(
164 } 222 }
165 Err(_) => None, 223 Err(_) => None,
166 } 224 }
225 } else if let Some(ref spinner_state_arc) = spinner_state_clone {
226 let mut state = spinner_state_arc.lock().unwrap();
227 if state.should_expand() {
228 let multi = state.expand().clone();
229 let pb = multi.add(
230 ProgressBar::new(1)
231 .with_prefix(
232 console::style(format!(
233 "{: <server_column_width$} connecting",
234 &server_name
235 ))
236 .for_stderr()
237 .yellow()
238 .to_string(),
239 )
240 .with_style(git_server_pb_style(current_timeout_clone.clone()).unwrap()),
241 );
242 pb.enable_steady_tick(Duration::from_millis(300));
243 Some(pb)
244 } else {
245 None
246 }
167 } else { 247 } else {
168 None 248 None
169 }; 249 };
@@ -191,7 +271,6 @@ pub async fn list_from_remotes(
191 } 271 }
192 } 272 }
193 273
194 // Create the list operation future - spawn_blocking to avoid blocking async runtime
195 let git_repo_path = git_repo.get_path().ok().map(|p| p.to_path_buf()); 274 let git_repo_path = git_repo.get_path().ok().map(|p| p.to_path_buf());
196 let url_clone = url.clone(); 275 let url_clone = url.clone();
197 let decoded_nostr_url_clone = decoded_nostr_url.clone(); 276 let decoded_nostr_url_clone = decoded_nostr_url.clone();
@@ -199,7 +278,6 @@ pub async fn list_from_remotes(
199 278
200 let list_future = async move { 279 let list_future = async move {
201 match tokio::task::spawn_blocking(move || { 280 match tokio::task::spawn_blocking(move || {
202 // Re-open repo in blocking thread (git2::Repository is not Send)
203 let git_repo = match git_repo_path { 281 let git_repo = match git_repo_path {
204 Some(path) => Repo::from_path(&path).ok(), 282 Some(path) => Repo::from_path(&path).ok(),
205 None => None, 283 None => None,
@@ -274,11 +352,9 @@ pub async fn list_from_remotes(
274 Err((url, error)) 352 Err((url, error))
275 } 353 }
276 Ok(state) => { 354 Ok(state) => {
277 // Determine sync status message and styling using existing functions
278 let status_msg = if state.is_empty() { 355 let status_msg = if state.is_empty() {
279 "empty repository".to_string() 356 "empty repository".to_string()
280 } else if let Some(nostr_state) = nostr_state { 357 } else if let Some(nostr_state) = nostr_state {
281 // Use existing generate_remote_sync_warnings to get detailed status
282 let mut temp_states = HashMap::new(); 358 let mut temp_states = HashMap::new();
283 temp_states.insert(url.clone(), (state.clone(), is_grasp_server)); 359 temp_states.insert(url.clone(), (state.clone(), is_grasp_server));
284 let remote_issues = identify_remote_sync_issues(git_repo, nostr_state, &temp_states); 360 let remote_issues = identify_remote_sync_issues(git_repo, nostr_state, &temp_states);
@@ -287,7 +363,6 @@ pub async fn list_from_remotes(
287 if warnings.is_empty() { 363 if warnings.is_empty() {
288 "in sync".to_string() 364 "in sync".to_string()
289 } else { 365 } else {
290 // Extract the message after "WARNING: <server> "
291 let warning = &warnings[0]; 366 let warning = &warnings[0];
292 let server_name = get_short_git_server_name(&url); 367 let server_name = get_short_git_server_name(&url);
293 let prefix = format!("WARNING: {} ", server_name); 368 let prefix = format!("WARNING: {} ", server_name);
@@ -296,7 +371,6 @@ pub async fn list_from_remotes(
296 .to_string() 371 .to_string()
297 } 372 }
298 } else { 373 } else {
299 // No nostr state to compare against
300 "success".to_string() 374 "success".to_string()
301 }; 375 };
302 376
@@ -333,20 +407,22 @@ pub async fn list_from_remotes(
333 .await; 407 .await;
334 408
335 let mut remote_states = HashMap::new(); 409 let mut remote_states = HashMap::new();
336 let mut all_succeeded = true; 410 let mut has_errors = false;
337 for result in results { 411 for result in results {
338 match result { 412 match result {
339 Ok((url, state, is_grasp_server)) => { 413 Ok((url, state, is_grasp_server)) => {
340 remote_states.insert(url, (state, is_grasp_server)); 414 remote_states.insert(url, (state, is_grasp_server));
341 } 415 }
342 Err((url, error)) => { 416 Err((url, error)) => {
343 all_succeeded = false; 417 has_errors = true;
344 let _ = term.write_line(&format!("failed to list from {}: {}", url, error)); 418 let _ = term.write_line(&format!("failed to list from {}: {}", url, error));
345 } 419 }
346 } 420 }
347 } 421 }
348 422
349 if all_succeeded { 423 if let Some(ref spinner_state_arc) = spinner_state {
424 spinner_state_arc.lock().unwrap().finish(has_errors);
425 } else if !has_errors {
350 let _ = progress_reporter.clear(); 426 let _ = progress_reporter.clear();
351 } 427 }
352 428