diff --git a/src/ui/deb.rs b/src/ui/deb.rs index 4bbfb01..69c04e9 100644 --- a/src/ui/deb.rs +++ b/src/ui/deb.rs @@ -50,8 +50,11 @@ struct Pipeline { /// State shared between [`DebUi`] and its sinks struct Shared { + multi: MultiProgress, top: ProgressBar, - pane: ProgressBar, + /// The rolling pane bar, created on demand: flows without subprocess + /// output (e.g. `pkh put`) never show it at all + pane: Mutex>, state: Mutex, tee: Mutex>, log_path: Mutex, @@ -85,34 +88,18 @@ impl DebUi { pb.enable_steady_tick(Duration::from_millis(80)); pb.set_style(spinner_style()); pb.set_prefix("Building package"); - pb.set_message("(starting…)"); - pb - } else { - ProgressBar::hidden() - }; - - let pane = if enabled { - let pb = multi.add(ProgressBar::new(0)); - pb.enable_steady_tick(Duration::from_millis(150)); - // No template margin: multi-line messages are only prefixed by - // the template on their first line, which would misalign the - // pane; each rendered line carries its own indent instead. - pb.set_style( - ProgressStyle::default_bar() - .template("{msg}") - .expect("valid template"), - ); - pb.set_message(" │ (starting…)"); pb } else { ProgressBar::hidden() }; + let pane = Mutex::new(None); let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string(); let log_path = default_log_path(×tamp); let ui = Self { shared: Arc::new(Shared { + multi: multi.clone(), top, pane, state: Mutex::new(Pipeline { @@ -198,7 +185,7 @@ impl DebUi { if self.shared.enabled { self.shared.top.set_style(spinner_style()); self.shared.top.set_message(label.to_string()); - self.shared.pane.set_message(""); + drop_pane(&self.shared); } } @@ -221,9 +208,8 @@ impl DebUi { return; } self.shared.top.disable_steady_tick(); - self.shared.pane.disable_steady_tick(); + drop_pane(&self.shared); self.shared.top.finish_and_clear(); - self.shared.pane.finish_and_clear(); } /// Success outcome body: clear the widget and print the artifacts, @@ -408,7 +394,44 @@ fn push_line(shared: &Shared, st: &mut Pipeline, kind: Kind, text: String) { let now = Instant::now(); if now.duration_since(st.last_draw) >= REDRAW_INTERVAL { st.last_draw = now; - shared.pane.set_message(render_pane(&st.lines)); + if let Some(pane) = ensure_pane(shared) { + pane.set_message(render_pane(&st.lines)); + } + } +} + +/// The pane bar, added to the terminal on the first call and reused after +/// +/// Returns `None` once the widget is suspended: a line racing the suspend +/// must not re-add a bar the cleanup just cleared. +fn ensure_pane(shared: &Shared) -> Option { + let mut pane = shared.pane.lock().unwrap(); + if let Some(pb) = pane.as_ref() { + return Some(pb.clone()); + } + if shared.suspended.load(Ordering::SeqCst) { + return None; + } + let pb = shared.multi.add(ProgressBar::new(0)); + pb.enable_steady_tick(Duration::from_millis(150)); + // No template margin: multi-line messages are only prefixed by the + // template on their first line, which would misalign the pane; each + // rendered line carries its own indent instead. + pb.set_style( + ProgressStyle::default_bar() + .template("{msg}") + .expect("valid template"), + ); + *pane = Some(pb.clone()); + Some(pb) +} + +/// Take the pane bar off the terminal; the next pushed line re-creates it +fn drop_pane(shared: &Shared) { + if let Some(pb) = shared.pane.lock().unwrap().take() { + pb.disable_steady_tick(); + pb.finish_and_clear(); + shared.multi.remove(&pb); } } @@ -638,6 +661,47 @@ mod tests { assert!(rendered.contains("a line that would overflow a narrow pane")); } + /// A fresh view has no pane at all: flows without subprocess output + /// (`pkh put`) must not render anything until a line arrives, and a + /// dropped or suspended pane stays gone + #[test] + fn pane_is_created_lazily_and_dropped_cleanly() { + let multi = MultiProgress::new(); + let shared = Shared { + multi: multi.clone(), + top: multi.add(ProgressBar::new(0)), + pane: Mutex::new(None), + state: Mutex::new(Pipeline { + classifier: Box::new(GenericClassifier::new()), + lines: VecDeque::new(), + errors: Vec::new(), + last_draw: Instant::now(), + bar_total: 0, + }), + tee: Mutex::new(None), + log_path: Mutex::new(std::env::temp_dir().join("pkh-pane-test.log")), + timestamp: String::new(), + enabled: true, + suspended: AtomicBool::new(false), + started: Instant::now(), + }; + + assert!(shared.pane.lock().unwrap().is_none()); + + ensure_pane(&shared).unwrap(); + assert!(shared.pane.lock().unwrap().is_some()); + // Later lines hit the stored bar instead of stacking another one + ensure_pane(&shared).unwrap(); + assert!(shared.pane.lock().unwrap().is_some()); + + drop_pane(&shared); + assert!(shared.pane.lock().unwrap().is_none()); + + // A line racing the suspend must not re-add the cleared bar + shared.suspended.store(true, Ordering::SeqCst); + assert!(ensure_pane(&shared).is_none()); + } + /// Best-effort ANSI escape stripper, enough for the assertions above fn strip_ansi(line: &str) -> String { let mut out = String::new();