Repository: denoland/deno
Pull request: feat: refresh "Download" progress bar with a spinner

This commit adds a spinner to "Download" progress bar and makes
it multiline, showing up to 4 lines of documents being downloaded.



https://github.com/user-attachments/assets/e8c10e15-bc8c-42c5-8169-f8b5bfcac566




Changed files:
filename: cli/util/progress_bar/mod.rs
patch: @@ -223,23 +223,23 @@ impl DrawThreadRenderer for ProgressBarInner {
       if state.entries.is_empty() {
         return String::new();
       }
-      let preferred_entry = state
+      let display_entries = state
         .entries
         .iter()
-        .find(|e| e.percent() > 0f64)
-        .or_else(|| state.entries.iter().last())
-        .unwrap();
+        .map(|e| ProgressDataDisplayEntry {
+          prompt: e.prompt,
+          message: e.message.to_string(),
+          position: e.position(),
+          total_size: e.total_size(),
+        })
+        .collect::<Vec<_>>();
+
       ProgressData {
         duration: state.start_time.elapsed(),
         terminal_width: size.cols,
         pending_entries: state.entries.len(),
         total_entries: state.total_entries,
-        display_entry: ProgressDataDisplayEntry {
-          prompt: preferred_entry.prompt,
-          message: preferred_entry.message.clone(),
-          position: preferred_entry.position(),
-          total_size: preferred_entry.total_size(),
-        },
+        display_entries,
         percent_done: {
           let mut total_percent_sum = 0f64;
           for entry in &state.entries {
@@ -273,7 +273,7 @@ impl ProgressBar {
           Arc::new(renderer::BarProgressBarRenderer)
         }
         ProgressBarStyle::TextOnly => {
-          Arc::new(renderer::TextOnlyProgressBarRenderer)
+          Arc::new(renderer::TextOnlyProgressBarRenderer::default())
         }
       }),
     }

filename: cli/util/progress_bar/renderer.rs
patch: @@ -1,5 +1,7 @@
 // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
 
+use std::sync::atomic::AtomicUsize;
+use std::sync::atomic::Ordering;
 use std::time::Duration;
 
 use deno_terminal::colors;
@@ -19,7 +21,7 @@ pub struct ProgressDataDisplayEntry {
 #[derive(Clone)]
 pub struct ProgressData {
   pub terminal_width: u32,
-  pub display_entry: ProgressDataDisplayEntry,
+  pub display_entries: Vec<ProgressDataDisplayEntry>,
   pub pending_entries: usize,
   pub percent_done: f64,
   pub total_entries: usize,
@@ -36,9 +38,13 @@ pub struct BarProgressBarRenderer;
 
 impl ProgressBarRenderer for BarProgressBarRenderer {
   fn render(&self, data: ProgressData) -> String {
+    // In `ProgressBarRenderer` we only care about first entry.
+    let Some(display_entry) = &data.display_entries.first() else {
+      return String::new();
+    };
     let (bytes_text, bytes_text_max_width) = {
-      let total_size = data.display_entry.total_size;
-      let pos = data.display_entry.position;
+      let total_size = display_entry.total_size;
+      let pos = display_entry.position;
       if total_size == 0 {
         (String::new(), 0)
       } else {
@@ -69,11 +75,11 @@ impl ProgressBarRenderer for BarProgressBarRenderer {
 
     let elapsed_text = get_elapsed_text(data.duration);
     let mut text = String::new();
-    if !data.display_entry.message.is_empty() {
+    if !display_entry.message.is_empty() {
       text.push_str(&format!(
         "{} {}{}\n",
         colors::green("Download"),
-        data.display_entry.message,
+        display_entry.message,
         bytes_text,
       ));
     }
@@ -106,7 +112,7 @@ impl ProgressBarRenderer for BarProgressBarRenderer {
     text.push(']');
 
     // suffix
-    if data.display_entry.message.is_empty() {
+    if display_entry.message.is_empty() {
       text.push_str(&colors::gray(bytes_text).to_string());
     }
     text.push_str(&colors::gray(total_text).to_string());
@@ -116,40 +122,82 @@ impl ProgressBarRenderer for BarProgressBarRenderer {
 }
 
 #[derive(Debug)]
-pub struct TextOnlyProgressBarRenderer;
+pub struct TextOnlyProgressBarRenderer {
+  last_tick: AtomicUsize,
+  start_time: std::time::Instant,
+}
+
+impl Default for TextOnlyProgressBarRenderer {
+  fn default() -> Self {
+    Self {
+      last_tick: Default::default(),
+      start_time: std::time::Instant::now(),
+    }
+  }
+}
 
+const SPINNER_CHARS: [&str; 8] = ["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"];
 impl ProgressBarRenderer for TextOnlyProgressBarRenderer {
   fn render(&self, data: ProgressData) -> String {
-    let bytes_text = {
-      let total_size = data.display_entry.total_size;
-      let pos = data.display_entry.position;
-      if total_size == 0 {
-        String::new()
-      } else {
-        format!(
-          " {}/{}",
-          human_download_size(pos, total_size),
-          human_download_size(total_size, total_size)
-        )
-      }
+    let last_tick = {
+      let last_tick = self.last_tick.load(Ordering::Relaxed);
+      let last_tick = (last_tick + 1) % 8;
+      self.last_tick.store(last_tick, Ordering::Relaxed);
+      last_tick
     };
+    let current_time = std::time::Instant::now();
+
+    let mut display_str = format!(
+      "{} {} ",
+      data.display_entries[0].prompt.as_text(),
+      SPINNER_CHARS[last_tick]
+    );
+
+    let elapsed_time = current_time - self.start_time;
+    let fmt_elapsed_time = get_elapsed_text(elapsed_time);
+
     let total_text = if data.total_entries <= 1 {
       String::new()
     } else {
       format!(
-        " ({}/{})",
+        " {}/{}",
         data.total_entries - data.pending_entries,
         data.total_entries
       )
     };
 
-    format!(
-      "{} {}{}{}",
-      data.display_entry.prompt.as_text(),
-      data.display_entry.message,
-      colors::gray(bytes_text),
-      colors::gray(total_text),
-    )
+    display_str.push_str(&format!("{}{}\n", fmt_elapsed_time, total_text));
+
+    for i in 0..4 {
+      let Some(display_entry) = data.display_entries.get(i) else {
+        display_str.push('\n');
+        continue;
+      };
+
+      let bytes_text = {
+        let total_size = display_entry.total_size;
+        let pos = display_entry.position;
+        if total_size == 0 {
+          String::new()
+        } else {
+          format!(
+            " {}/{}",
+            human_download_size(pos, total_size),
+            human_download_size(total_size, total_size)
+          )
+        }
+      };
+
+      let message = display_entry
+        .message
+        .replace("https://registry.npmjs.org/", "npm:")
+        .replace("https://jsr.io/", "jsr:");
+      display_str.push_str(
+        &colors::gray(format!(" - {}{}\n", message, bytes_text)).to_string(),
+      );
+    }
+
+    display_str
   }
 }
 
@@ -165,6 +213,7 @@ mod test {
   use super::*;
   use pretty_assertions::assert_eq;
   use std::time::Duration;
+  use test_util::assert_contains;
 
   #[test]
   fn should_get_elapsed_text() {
@@ -197,12 +246,12 @@ mod test {
   fn should_render_bar_progress() {
     let renderer = BarProgressBarRenderer;
     let mut data = ProgressData {
-      display_entry: ProgressDataDisplayEntry {
+      display_entries: vec![ProgressDataDisplayEntry {
         prompt: ProgressMessagePrompt::Download,
         message: "data".to_string(),
         position: 0,
         total_size: 10 * BYTES_TO_KIB,
-      },
+      }],
       duration: Duration::from_secs(1),
       pending_entries: 1,
       total_entries: 1,
@@ -220,8 +269,8 @@ mod test {
     );
 
     data.percent_done = 0.5f64;
-    data.display_entry.position = 5 * BYTES_TO_KIB;
-    data.display_entry.message = String::new();
+    data.display_entries[0].position = 5 * BYTES_TO_KIB;
+    data.display_entries[0].message = "".to_string();
     data.total_entries = 3;
     let text = renderer.render(data.clone());
     let text = test_util::strip_ansi_codes(&text);
@@ -235,14 +284,14 @@ mod test {
 
     data.terminal_width = 50;
     data.pending_entries = 0;
-    data.display_entry.position = 10 * BYTES_TO_KIB;
+    data.display_entries[0].position = 10 * BYTES_TO_KIB;
     data.percent_done = 1.0f64;
     let text = renderer.render(data.clone());
     let text = test_util::strip_ansi_codes(&text);
     assert_eq!(text, "[00:01] [###########] 10.00KiB/10.00KiB (3/3)",);
 
-    data.display_entry.position = 0;
-    data.display_entry.total_size = 0;
+    data.display_entries[0].position = 0;
+    data.display_entries[0].total_size = 0;
     data.pending_entries = 0;
     data.total_entries = 1;
     let text = renderer.render(data);
@@ -252,14 +301,14 @@ mod test {
 
   #[test]
   fn should_render_text_only_progress() {
-    let renderer = TextOnlyProgressBarRenderer;
+    let renderer = TextOnlyProgressBarRenderer::default();
     let mut data = ProgressData {
-      display_entry: ProgressDataDisplayEntry {
+      display_entries: vec![ProgressDataDisplayEntry {
         prompt: ProgressMessagePrompt::Blocking,
         message: "data".to_string(),
         position: 0,
         total_size: 10 * BYTES_TO_KIB,
-      },
+      }],
       duration: Duration::from_secs(1),
       pending_entries: 1,
       total_entries: 3,
@@ -268,14 +317,16 @@ mod test {
     };
     let text = renderer.render(data.clone());
     let text = test_util::strip_ansi_codes(&text);
-    assert_eq!(text, "Blocking data 0.00KiB/10.00KiB (2/3)");
+    assert_contains!(text, "Blocking ⣯");
+    assert_contains!(text, "2/3\n - data 0.00KiB/10.00KiB\n\n\n\n");
 
     data.pending_entries = 0;
     data.total_entries = 1;
-    data.display_entry.position = 0;
-    data.display_entry.total_size = 0;
+    data.display_entries[0].position = 0;
+    data.display_entries[0].total_size = 0;
     let text = renderer.render(data);
     let text = test_util::strip_ansi_codes(&text);
-    assert_eq!(text, "Blocking data");
+    assert_contains!(text, "Blocking ⣟");
+    assert_contains!(text, "\n - data\n\n\n\n");
   }
 }