diff --git a/Cargo.lock b/Cargo.lock index 216d4cc..ea9a279 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,12 +76,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - [[package]] name = "assert-json-diff" version = "2.0.2" @@ -960,7 +954,6 @@ dependencies = [ "portable-atomic", "unicode-width", "unit-prefix", - "vt100", "web-time", ] @@ -1115,7 +1108,6 @@ dependencies = [ "thiserror", "tokio", "tracing", - "tracing-indicatif", "tracing-subscriber", "url", "urlencoding", @@ -2032,18 +2024,6 @@ dependencies = [ "valuable", ] -[[package]] -name = "tracing-indicatif" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1ef6990e0438749f0080573248e96631171a0b5ddfddde119aa5ba8c3a9c47e" -dependencies = [ - "indicatif", - "tracing", - "tracing-core", - "tracing-subscriber", -] - [[package]] name = "tracing-log" version = "0.2.0" @@ -2174,27 +2154,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vt100" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" -dependencies = [ - "itoa", - "unicode-width", - "vte", -] - -[[package]] -name = "vte" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" -dependencies = [ - "arrayvec", - "memchr", -] - [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index f3460fc..32b5cf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,6 @@ libc = "0.2" # Logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -tracing-indicatif = "0.3" [dev-dependencies] tempfile = "3" diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index d42bcf3..eb75cca 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -40,7 +40,7 @@ fn stage_spinner(stage: u8, total: u8, msg: &str, robot_mode: bool) -> ProgressB if robot_mode { return ProgressBar::hidden(); } - let pb = ProgressBar::new_spinner(); + let pb = crate::cli::progress::multi().add(ProgressBar::new_spinner()); pb.set_style( ProgressStyle::default_spinner() .template("{spinner:.blue} {msg}") diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5e6e416..fb9cfae 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,7 @@ //! CLI module with clap command definitions. pub mod commands; +pub mod progress; use clap::{Parser, Subcommand}; use std::io::IsTerminal; diff --git a/src/cli/progress.rs b/src/cli/progress.rs new file mode 100644 index 0000000..fadea11 --- /dev/null +++ b/src/cli/progress.rs @@ -0,0 +1,119 @@ +//! Shared progress bar infrastructure. +//! +//! All progress bars must be created via [`multi()`] to ensure coordinated +//! rendering. The [`SuspendingWriter`] suspends the multi-progress before +//! writing tracing output, preventing log lines from interleaving with +//! progress bar animations. + +use indicatif::MultiProgress; +use std::io::Write; +use std::sync::LazyLock; +use tracing_subscriber::fmt::MakeWriter; + +/// Global multi-progress that coordinates all progress bar rendering. +/// +/// Every `ProgressBar` displayed to the user **must** be registered via +/// `multi().add(bar)`. Standalone bars bypass the coordination and will +/// fight with other bars for the terminal line, causing rapid flashing. +static MULTI: LazyLock = LazyLock::new(MultiProgress::new); + +/// Returns the shared [`MultiProgress`] instance. +pub fn multi() -> &'static MultiProgress { + &MULTI +} + +/// A tracing `MakeWriter` that suspends the shared [`MultiProgress`] while +/// writing, so log output doesn't interleave with progress bar animations. +/// +/// # How it works +/// +/// `MultiProgress::suspend` temporarily clears all active progress bars from +/// the terminal, executes the closure (which writes the log line), then +/// redraws the bars. This ensures a clean, flicker-free display even when +/// logging happens concurrently with progress updates. +#[derive(Clone)] +pub struct SuspendingWriter; + +/// Writer returned by [`SuspendingWriter`] that buffers a single log line +/// and flushes it inside a `MultiProgress::suspend` call. +pub struct SuspendingWriterInner { + buf: Vec, +} + +impl Write for SuspendingWriterInner { + fn write(&mut self, data: &[u8]) -> std::io::Result { + self.buf.extend_from_slice(data); + Ok(data.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + // Nothing to do — actual flush happens on drop. + Ok(()) + } +} + +impl Drop for SuspendingWriterInner { + fn drop(&mut self) { + if !self.buf.is_empty() { + let buf = std::mem::take(&mut self.buf); + MULTI.suspend(|| { + let _ = std::io::stderr().write_all(&buf); + let _ = std::io::stderr().flush(); + }); + } + } +} + +impl<'a> MakeWriter<'a> for SuspendingWriter { + type Writer = SuspendingWriterInner; + + fn make_writer(&'a self) -> Self::Writer { + SuspendingWriterInner { buf: Vec::new() } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use indicatif::ProgressBar; + + #[test] + fn multi_returns_same_instance() { + let a = multi() as *const MultiProgress; + let b = multi() as *const MultiProgress; + assert!(std::ptr::eq(a, b)); + } + + #[test] + fn added_bar_is_tracked() { + let bar = multi().add(ProgressBar::new_spinner()); + bar.set_message("test"); + bar.finish_and_clear(); + } + + #[test] + fn hidden_bar_works_with_multi() { + let bar = multi().add(ProgressBar::hidden()); + bar.set_message("hidden"); + bar.finish_and_clear(); + } + + #[test] + fn suspending_writer_buffers_and_flushes() { + let writer = SuspendingWriter; + let mut w = MakeWriter::make_writer(&writer); + // Write should succeed and buffer data + let n = w.write(b"test log line\n").unwrap(); + assert_eq!(n, 14); + // Drop flushes via suspend — no panic means it works + drop(w); + } + + #[test] + fn suspending_writer_empty_does_not_flush() { + let writer = SuspendingWriter; + let w = MakeWriter::make_writer(&writer); + // Drop with empty buffer — should be a no-op + drop(w); + } +} diff --git a/src/main.rs b/src/main.rs index 1293b68..d957ce7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,21 +41,20 @@ async fn main() { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } - // Initialize logging with indicatif support for clean progress bar output - let indicatif_layer = tracing_indicatif::IndicatifLayer::new(); - + // Initialize logging with progress-bar-aware writer. + // SuspendingWriter suspends the shared MultiProgress before each log line, + // preventing log output from interleaving with progress bar animations. tracing_subscriber::registry() .with( tracing_subscriber::fmt::layer() .with_target(false) - .with_writer(indicatif_layer.get_stderr_writer()), + .with_writer(lore::cli::progress::SuspendingWriter), ) .with( EnvFilter::from_default_env() .add_directive("lore=info".parse().unwrap()) .add_directive("warn".parse().unwrap()), ) - .with(indicatif_layer) .init(); let cli = Cli::parse();