From 1484387c177d89392ac059cbd43e6a72f7541bab Mon Sep 17 00:00:00 2001 From: atagen Date: Sun, 7 Jun 2026 19:56:26 +1000 Subject: [PATCH] progress: print rich status on long runners --- Cargo.lock | 53 +++++-- Cargo.toml | 1 + src/loaders.rs | 153 ++++++------------- src/main.rs | 1 + src/nix_dev_env.rs | 6 + src/nix_progress.rs | 362 ++++++++++++++++++++++++++++++++++++++++++++ src/progress.rs | 20 +++ 7 files changed, 471 insertions(+), 125 deletions(-) create mode 100644 src/nix_progress.rs diff --git a/Cargo.lock b/Cargo.lock index 0398907..98f99dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,7 @@ name = "cade" version = "0.1.2" dependencies = [ "anyhow", + "cognos", "libc", "microxdg", "pound", @@ -30,6 +31,17 @@ dependencies = [ "whoami", ] +[[package]] +name = "cognos" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde1effdc2ca9df723c82f6b5f394de16cd8ca226f5ca6c6f5313f7f98dcf51c" +dependencies = [ + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -200,17 +212,11 @@ dependencies = [ "smallvec", ] -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - [[package]] name = "serde" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -218,18 +224,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -238,15 +244,26 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -368,3 +385,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 9b1cea9..beba6f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ codegen-units = 1 [dependencies] anyhow = "1.0.99" +cognos = "1.0" libc = "0.2" microxdg = "0.2.0" pound = "0.1.4" diff --git a/src/loaders.rs b/src/loaders.rs index 7dd883c..7f62807 100644 --- a/src/loaders.rs +++ b/src/loaders.rs @@ -1,11 +1,11 @@ use crate::{ config, + nix_progress::NixProgress, types::EnvSet, verbosity::{self, Verbosity}, }; use anyhow::{Context, Result, bail}; use std::{ - collections::VecDeque, io::{IsTerminal, Read, Write}, path::Path, process::{Command, Output, Stdio}, @@ -17,9 +17,6 @@ pub(crate) use crate::nix_dev_env::{load_flake, load_shell}; const DEFAULT_LONG_RUNNING_WARNING_AFTER: Duration = Duration::from_secs(5); const LONG_RUNNING_POLL_INTERVAL: Duration = Duration::from_millis(100); -const RECENT_OUTPUT_LINES: usize = 5; -const RECENT_OUTPUT_LINE_BYTES: usize = 4 * 1024; -const DISPLAY_LINE_CHARS: usize = 200; fn long_running_warning_after() -> Duration { config::long_running_warning_ms() @@ -38,77 +35,6 @@ struct StreamEvent { data: Vec, } -struct RecentLines { - lines: VecDeque, - current: Vec, -} - -impl RecentLines { - fn new() -> Self { - Self { - lines: VecDeque::with_capacity(RECENT_OUTPUT_LINES), - current: Vec::new(), - } - } - - fn push(&mut self, chunk: &[u8]) { - for &byte in chunk { - match byte { - b'\n' => self.finish_current_line(), - b'\r' => { - self.current.clear(); - } - _ => { - if self.current.len() < RECENT_OUTPUT_LINE_BYTES { - self.current.push(byte); - } - } - } - } - } - - fn finish_current_line(&mut self) { - let line = sanitize_display_line(&self.current); - self.current.clear(); - if line.is_empty() { - return; - } - if self.lines.len() == RECENT_OUTPUT_LINES { - self.lines.pop_front(); - } - self.lines.push_back(line); - } - - fn lines(&self) -> Vec { - let mut lines: Vec = self.lines.iter().cloned().collect(); - let current = sanitize_display_line(&self.current); - if !current.is_empty() { - lines.push(current); - } - let keep_from = lines.len().saturating_sub(RECENT_OUTPUT_LINES); - lines.into_iter().skip(keep_from).collect() - } -} - -fn sanitize_display_line(raw: &[u8]) -> String { - let text = String::from_utf8_lossy(raw); - let mut out = String::new(); - for ch in text.chars() { - if ch == '\t' { - out.push_str(" "); - } else if ch.is_control() { - out.push(' '); - } else { - out.push(ch); - } - if out.chars().count() >= DISPLAY_LINE_CHARS { - out.push_str("..."); - break; - } - } - out.trim().to_string() -} - struct LongRunningProgress<'a> { what: &'a str, enabled: bool, @@ -130,13 +56,13 @@ impl<'a> LongRunningProgress<'a> { } } - fn show(&mut self, recent: &[String]) { + fn show(&mut self, recent: &[String], bar: Option<&str>) { self.shown = true; if !self.enabled { return; } if self.interactive { - self.render(recent); + self.render(recent, bar); } else { eprintln!( "cade: {} is taking a long time; press Ctrl-C to stop and inspect the command.", @@ -150,9 +76,9 @@ impl<'a> LongRunningProgress<'a> { self.shown && self.enabled && self.interactive } - fn update(&mut self, recent: &[String]) { + fn update(&mut self, recent: &[String], bar: Option<&str>) { if self.wants_live() { - self.render(recent); + self.render(recent, bar); } } @@ -170,8 +96,8 @@ impl<'a> LongRunningProgress<'a> { } } - fn render(&mut self, recent: &[String]) { - let block = self.block(recent); + fn render(&mut self, recent: &[String], bar: Option<&str>) { + let block = self.block(recent, bar); if block == self.last_block { return; } @@ -193,7 +119,7 @@ impl<'a> LongRunningProgress<'a> { self.last_block.clear(); } - fn block(&self, recent: &[String]) -> Vec { + fn block(&self, recent: &[String], bar: Option<&str>) -> Vec { let mut lines = vec![format!( "cade: {} is taking a long time; press Ctrl-C to stop and inspect the command.", self.what @@ -202,6 +128,9 @@ impl<'a> LongRunningProgress<'a> { lines.push("cade: recent output:".to_string()); lines.extend(recent.iter().map(|line| format!(" {line}"))); } + if let Some(bar) = bar { + lines.push(bar.to_string()); + } lines } } @@ -237,20 +166,25 @@ fn handle_stream_event( event: StreamEvent, stdout: &mut Vec, stderr: &mut Vec, - recent_stderr: &mut RecentLines, + nix: &mut NixProgress, progress: Option<&mut LongRunningProgress<'_>>, ) { match event.kind { StreamKind::Stdout => stdout.extend(event.data), StreamKind::Stderr => { stderr.extend(&event.data); - recent_stderr.push(&event.data); + nix.push(&event.data); + let recent = nix.recent_lines(); + let bar = nix.bar_line(); match progress { // No spinner owns the terminal: drive the standalone widget. - Some(progress) if progress.wants_live() => progress.update(&recent_stderr.lines()), + Some(progress) if progress.wants_live() => progress.update(&recent, bar.as_deref()), Some(_) => {} // Feed the active activation spinner instead. - None => crate::progress::set_recent(recent_stderr.lines()), + None => { + crate::progress::set_recent(recent); + crate::progress::set_nix_bar(bar); + } } } } @@ -277,7 +211,7 @@ pub(crate) fn run_checked(mut cmd: Command, what: &str) -> Result> { let mut stdout = Vec::new(); let mut stderr = Vec::new(); - let mut recent_stderr = RecentLines::new(); + let mut nix = NixProgress::new(); // The activation spinner, when present, subsumes the standalone widget. let mut progress = (!crate::progress::is_active()).then(|| LongRunningProgress::new(what)); let mut warned = false; @@ -291,10 +225,13 @@ pub(crate) fn run_checked(mut cmd: Command, what: &str) -> Result> { if !warned && start.elapsed() >= warn_after { warned = true; match &mut progress { - Some(progress) => progress.show(&recent_stderr.lines()), - None => crate::progress::mark_long_running(format!( - "cade: {what} is taking a long time; press Ctrl-C to stop and inspect the command." - )), + Some(progress) => progress.show(&nix.recent_lines(), nix.bar_line().as_deref()), + None => { + crate::progress::mark_long_running(format!( + "cade: {what} is taking a long time; press Ctrl-C to stop and inspect the command." + )); + crate::progress::set_nix_bar(nix.bar_line()); + } } } @@ -307,13 +244,9 @@ pub(crate) fn run_checked(mut cmd: Command, what: &str) -> Result> { }; match rx.recv_timeout(wait_for) { - Ok(event) => handle_stream_event( - event, - &mut stdout, - &mut stderr, - &mut recent_stderr, - progress.as_mut(), - ), + Ok(event) => { + handle_stream_event(event, &mut stdout, &mut stderr, &mut nix, progress.as_mut()) + } Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => { break child.wait().context("waiting for command status")?; @@ -325,16 +258,10 @@ pub(crate) fn run_checked(mut cmd: Command, what: &str) -> Result> { let _ = reader.join(); } while let Ok(event) = rx.try_recv() { - handle_stream_event( - event, - &mut stdout, - &mut stderr, - &mut recent_stderr, - progress.as_mut(), - ); + handle_stream_event(event, &mut stdout, &mut stderr, &mut nix, progress.as_mut()); } if let Some(mut progress) = progress { - progress.finish(&recent_stderr.lines()); + progress.finish(&nix.recent_lines()); } let out = Output { @@ -345,15 +272,21 @@ pub(crate) fn run_checked(mut cmd: Command, what: &str) -> Result> { verbosity::log(Verbosity::Trace, format_args!("cade: finished {what}.")); if !out.status.success() { - let stderr = String::from_utf8_lossy(&out.stderr); - let stderr = stderr.trim(); + // prefer the de-jsonified nix messages; fall back to raw stderr for + // non-nix commands (whose stderr is plain text already). + let summary = if nix.saw_nix() { + nix.error_text() + } else { + String::from_utf8_lossy(&out.stderr).into_owned() + }; + let summary = summary.trim(); bail!( "{what} failed ({}){}", out.status, - if stderr.is_empty() { + if summary.is_empty() { String::new() } else { - format!(":\n{stderr}") + format!(":\n{summary}") } ); } diff --git a/src/main.rs b/src/main.rs index 05df39e..751232e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod envs; mod expand; mod loaders; mod nix_dev_env; +mod nix_progress; mod path_resolve; mod progress; mod shells; diff --git a/src/nix_dev_env.rs b/src/nix_dev_env.rs index 53f5e97..e55ac70 100644 --- a/src/nix_dev_env.rs +++ b/src/nix_dev_env.rs @@ -171,6 +171,7 @@ pub(crate) fn load_flake(target: &FlakeTarget, profile: Option) -> Resu if !target.installable.is_empty() { proc.arg(&target.installable); } + add_log_format(&mut proc); add_profile(&mut proc, profile.as_deref()); add_env_command(&mut proc); @@ -188,6 +189,7 @@ pub(crate) fn load_shell(file: &Path, profile: Option) -> Result) { } } +fn add_log_format(proc: &mut Command) { + proc.args(["--log-format", "internal-json"]); +} + fn add_env_command(proc: &mut Command) { // Use absolute sh/env paths when possible. Once inside `nix develop`, // PATH may intentionally be incomplete or rewritten by the shell setup. diff --git a/src/nix_progress.rs b/src/nix_progress.rs new file mode 100644 index 0000000..e511f06 --- /dev/null +++ b/src/nix_progress.rs @@ -0,0 +1,362 @@ +//! Reconstructs nix progress from `--log-format internal-json` output. + +use cognos::internal::json::{Actions, Activities, ResultType, Verbosity, parse_line}; +use std::collections::{HashMap, VecDeque}; + +const RECENT_LINES: usize = 5; // rolling log lines kept for the display +const LINE_BYTES: usize = 4 * 1024; // cap on a single buffered line +const DISPLAY_CHARS: usize = 200; // truncate display lines past this +const TRANSCRIPT_CAP: usize = 200; // de-jsonified lines kept for a failure summary +const BAR_CELLS: usize = 24; // width of the rendered bar + +const BAR: &str = "\x1b[34m"; +const RESET: &str = "\x1b[0m"; + +#[derive(Default, Clone, Copy)] +struct Count { + done: u64, + expected: u64, +} + +#[derive(Default)] +pub(crate) struct NixProgress { + carry: Vec, // bytes of an as-yet-unterminated line + recent: VecDeque, // last few display lines (the rolling log) + transcript: VecDeque, // de-jsonified msg/build-log lines, for errors + saw_nix: bool, // any `@nix` event parsed at all + + builds: Count, + copies: Count, + builds_id: Option, // id of the top-level Builds activity + copies_id: Option, // id of the top-level CopyPaths activity + transfers: HashMap, // file-transfer id -> (done, expected) bytes +} + +impl NixProgress { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn push(&mut self, chunk: &[u8]) { + for &byte in chunk { + match byte { + b'\n' => { + let line = std::mem::take(&mut self.carry); + self.line(&line); + } + b'\r' => self.carry.clear(), + _ => { + if self.carry.len() < LINE_BYTES { + self.carry.push(byte); + } + } + } + } + } + + fn line(&mut self, raw: &[u8]) { + let text = String::from_utf8_lossy(raw); + if let Some(action) = parse_line(&text) { + self.saw_nix = true; + self.observe(action); + } else { + let line = sanitize(raw); + if !line.is_empty() { + self.push_recent(line); + } + } + } + + fn observe(&mut self, action: Actions) { + match action { + Actions::Start { + id, + level, + text, + activity, + .. + } => { + match activity { + Activities::Builds => self.builds_id = Some(id), + Activities::CopyPaths => self.copies_id = Some(id), + Activities::FileTransfer => { + self.transfers.entry(id).or_insert((0, 0)); + } + _ => {} + } + let lively = matches!( + activity, + Activities::Build + | Activities::Substitute + | Activities::CopyPath + | Activities::FileTransfer + ); + if lively && level <= Verbosity::Talkative && !text.is_empty() { + self.push_recent(sanitize(text.as_bytes())); + } + } + Actions::Result { + id, + result_type, + fields, + } => match result_type { + ResultType::Progress => { + let done = fields.first().and_then(|v| v.as_u64()).unwrap_or(0); + let expected = fields.get(1).and_then(|v| v.as_u64()).unwrap_or(0); + let count = Count { done, expected }; + if self.builds_id == Some(id) { + self.builds = count; + } else if self.copies_id == Some(id) { + self.copies = count; + } else if let Some(bytes) = self.transfers.get_mut(&id) { + *bytes = (done, expected); + } + } + ResultType::BuildLogLine | ResultType::PostBuildLogLine => { + if let Some(text) = fields.first().and_then(|v| v.as_str()) { + let line = sanitize(text.as_bytes()); + if !line.is_empty() { + self.push_recent(line.clone()); + self.push_transcript(line); + } + } + } + _ => {} + }, + Actions::Message { level, msg, .. } => { + let line = sanitize(msg.as_bytes()); + if line.is_empty() { + return; + } + self.push_transcript(line.clone()); + if level <= Verbosity::Notice { + self.push_recent(line); + } + } + Actions::Stop { .. } => {} + } + } + + fn push_recent(&mut self, line: String) { + if self.recent.len() == RECENT_LINES { + self.recent.pop_front(); + } + self.recent.push_back(line); + } + + fn push_transcript(&mut self, line: String) { + if self.transcript.len() == TRANSCRIPT_CAP { + self.transcript.pop_front(); + } + self.transcript.push_back(line); + } + + pub(crate) fn recent_lines(&self) -> Vec { + self.recent.iter().cloned().collect() + } + + pub(crate) fn saw_nix(&self) -> bool { + self.saw_nix + } + + pub(crate) fn error_text(&self) -> String { + self.transcript + .iter() + .cloned() + .collect::>() + .join("\n") + } + + fn fraction(&self) -> Option { + let done = self.builds.done + self.copies.done; + let expected = self.builds.expected + self.copies.expected; + (expected > 0).then(|| (done as f32 / expected as f32).clamp(0.0, 1.0)) + } + + fn status_text(&self) -> String { + let mut parts = Vec::new(); + if self.builds.expected > 0 { + parts.push(format!( + "{}/{} built", + self.builds.done, self.builds.expected + )); + } + if self.copies.expected > 0 { + parts.push(format!( + "{}/{} copied", + self.copies.done, self.copies.expected + )); + } + let (done, expected) = self + .transfers + .values() + .fold((0u64, 0u64), |(d, e), (td, te)| (d + td, e + te)); + if expected > 0 { + parts.push(format!("{:.1}/{:.0} MB", mb(done), mb(expected))); + } + parts.join(" · ") + } + + pub(crate) fn bar_line(&self) -> Option { + let fraction = self.fraction()?; + Some(render_bar(fraction, &self.status_text())) + } +} + +fn mb(bytes: u64) -> f64 { + bytes as f64 / 1_000_000.0 +} + +fn render_bar(progress: f32, status: &str) -> String { + let progress = progress.clamp(0.0, 1.0); + let filled = progress * BAR_CELLS as f32; + let full = filled.floor() as usize; + let half = (filled - full as f32) >= 0.5 && full < BAR_CELLS; + + let mut bar = String::from("["); + bar.push_str(BAR); + for _ in 0..full { + bar.push('━'); // heavy = filled + } + let mut used = full; + if half { + bar.push('╸'); // half leading edge, sub-cell precision + used += 1; + } + for _ in used..BAR_CELLS { + bar.push('─'); // light = remaining + } + bar.push_str(RESET); + bar.push(']'); + + let status = if status.is_empty() { + String::new() + } else { + format!(" {status}") + }; + format!("{bar} {:>3.0}%{status}", progress * 100.0) +} + +fn sanitize(raw: &[u8]) -> String { + let text = String::from_utf8_lossy(raw); + let mut out = String::new(); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\x1b' { + if chars.peek() == Some(&'[') { + chars.next(); + for c in chars.by_ref() { + if ('@'..='~').contains(&c) { + break; + } + } + } + continue; + } + if ch == '\t' { + out.push_str(" "); + } else if ch.is_control() { + out.push(' '); + } else { + out.push(ch); + } + if out.chars().count() >= DISPLAY_CHARS { + out.push_str("..."); + break; + } + } + out.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + // event shapes lifted from a real `nix-build --log-format internal-json`: + // top-level Builds(104)/CopyPaths(103) parents carry the aggregate Progress. + fn feed(np: &mut NixProgress, lines: &[&str]) { + for line in lines { + np.push(line.as_bytes()); + np.push(b"\n"); + } + } + + #[test] + fn builds_drive_fraction_and_status() { + let mut np = NixProgress::new(); + feed( + &mut np, + &[ + r#"@nix {"action":"start","id":104,"level":0,"parent":0,"text":"","type":104}"#, + r#"@nix {"action":"result","id":104,"type":105,"fields":[0,3,0,0]}"#, + r#"@nix {"action":"start","id":900,"level":3,"parent":0,"text":"building '/nix/store/abc-hello.drv'","type":105}"#, + r#"@nix {"action":"result","id":104,"type":105,"fields":[2,3,1,0]}"#, + ], + ); + assert!(np.saw_nix()); + assert!((np.fraction().unwrap() - 2.0 / 3.0).abs() < 1e-6); + let bar = np.bar_line().unwrap(); + assert!(bar.contains("2/3 built"), "{bar}"); + assert!(bar.contains("67%"), "{bar}"); + // the leaf build description shows in the rolling log + assert!( + np.recent_lines() + .iter() + .any(|l| l.contains("building '/nix/store/abc-hello.drv'")), + "{:?}", + np.recent_lines() + ); + } + + #[test] + fn copies_and_downloads_join_status() { + let mut np = NixProgress::new(); + feed( + &mut np, + &[ + r#"@nix {"action":"start","id":103,"level":0,"parent":0,"text":"","type":103}"#, + r#"@nix {"action":"result","id":103,"type":105,"fields":[4,10,0,0]}"#, + r#"@nix {"action":"start","id":201,"level":4,"parent":0,"text":"downloading 'https://cache/abc.nar'","type":101}"#, + r#"@nix {"action":"result","id":201,"type":105,"fields":[5000000,20000000,0,0]}"#, + ], + ); + let bar = np.bar_line().unwrap(); + assert!(bar.contains("4/10 copied"), "{bar}"); + assert!(bar.contains("5.0/20 MB"), "{bar}"); + } + + #[test] + fn non_nix_lines_pass_through() { + let mut np = NixProgress::new(); + feed(&mut np, &["hello from a hook", "another plain line"]); + assert!(!np.saw_nix()); + assert!(np.bar_line().is_none()); + assert_eq!( + np.recent_lines(), + vec!["hello from a hook", "another plain line"] + ); + } + + #[test] + fn messages_feed_roll_and_error_summary() { + let mut np = NixProgress::new(); + feed( + &mut np, + &[ + r#"@nix {"action":"msg","level":0,"msg":"error: build failed"}"#, + r#"@nix {"action":"result","id":7,"type":101,"fields":["cc: fatal error"]}"#, + ], + ); + // ANSI is stripped from the visible line + assert!(np.recent_lines().iter().any(|l| l == "error: build failed")); + let err = np.error_text(); + assert!(err.contains("error: build failed"), "{err}"); + assert!(err.contains("cc: fatal error"), "{err}"); + } + + #[test] + fn sanitize_strips_ansi_and_controls() { + assert_eq!(sanitize(b"\x1b[31mred\x1b[0m text"), "red text"); + assert_eq!(sanitize(b"tab\tsep"), "tab sep"); + } +} diff --git a/src/progress.rs b/src/progress.rs index 5ffad03..05a80c8 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -37,6 +37,7 @@ struct State { frame: usize, long_running: bool, recent: Vec, + nix_bar: Option, visible_rows: usize, } @@ -47,6 +48,9 @@ impl State { let mut lines = vec![format!("[{colour}{frame}{RESET}] {}", self.message)]; if self.long_running { lines.extend(self.recent.iter().map(|line| format!(" {line}"))); + if let Some(bar) = &self.nix_bar { + lines.push(bar.clone()); + } } lines } @@ -206,6 +210,19 @@ pub fn set_recent(lines: Vec) { } } +/// Replace the reconstructed nix progress bar shown below the recent output. +pub fn set_nix_bar(bar: Option) { + if !is_active() { + return; + } + if let Some(state) = STATE.lock().unwrap().as_mut() { + state.nix_bar = bar; + if state.long_running { + state.render(); + } + } +} + /// Flip the spinner into its yellow long-running state with a new message. pub fn mark_long_running(message: String) { if !is_active() { @@ -266,6 +283,7 @@ pub fn start(subject: &str) -> Spinner { frame: 0, long_running: false, recent: Vec::new(), + nix_bar: None, visible_rows: 0, }); @@ -393,6 +411,7 @@ mod tests { frame: 0, long_running: true, recent: vec!["line2".to_string(), "line3".to_string()], + nix_bar: None, visible_rows: 3, }; @@ -413,6 +432,7 @@ mod tests { frame: 0, long_running: false, recent: vec!["line".to_string()], + nix_bar: None, visible_rows: 1, };