Files
pkh/src/ui/deb.rs
T
vhaudiquet ff41edbd47 ui: create the rolling pane lazily, on its first line
DebUi seeded both widgets with a "(starting...)" placeholder,
replaced as soon as real content arrived during a build. pkh put
reports through the same view but runs no subprocess, so nothing
ever fed the pane: its seed line stayed on screen for the whole
upload, stacked under the per-file byte progress.

Drop the seeds and add the pane bar to the terminal only when the
first classified line arrives; a phase change (and the final
suspend) takes it off again. Flows without subprocess output now
render the status bar alone.
2026-09-21 15:20:17 +02:00

723 lines
24 KiB
Rust

//! Live build view (`pkh deb`, `pkh build`): a status bar with the current
//! build phase on top and a rolling pane of rewritten log lines below
//! ("a terminal in the terminal").
//!
//! Subprocess output is captured through a [`LineSink`] implementation,
//! rewritten by classifiers ([`crate::logfmt`]) and rendered in place
//! with indicatif, so pkh's own log lines keep printing above the widget via
//! `indicatif-log-bridge`. Every raw captured line is also tee'd to a log
//! file under the pkh cache directory.
use std::collections::VecDeque;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crossterm::{cursor, execute, style::Stylize, terminal::Clear, terminal::ClearType};
use directories::ProjectDirs;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::context::{LineSink, Stream};
use crate::logfmt::{Action, Classifier, GenericClassifier};
use crate::report::BuildTarget;
/// Number of lines displayed in the rolling pane
const PANE_LINES: usize = 10;
/// Minimum interval between pane redraws
const REDRAW_INTERVAL: Duration = Duration::from_millis(50);
/// Visual kind of a pane line, driving its color
#[derive(Debug, Clone, Copy, PartialEq)]
enum Kind {
Normal,
Warning,
Error,
}
/// Mutable state shared between the sink and the widget
struct Pipeline {
classifier: Box<dyn Classifier>,
lines: VecDeque<(Kind, String)>,
errors: Vec<String>,
last_draw: Instant,
bar_total: u64,
}
/// State shared between [`DebUi`] and its sinks
struct Shared {
multi: MultiProgress,
top: ProgressBar,
/// The rolling pane bar, created on demand: flows without subprocess
/// output (e.g. `pkh put`) never show it at all
pane: Mutex<Option<ProgressBar>>,
state: Mutex<Pipeline>,
tee: Mutex<Option<File>>,
log_path: Mutex<PathBuf>,
timestamp: String,
enabled: bool,
/// Set once the widget has been removed from the terminal; afterwards all
/// rendering is skipped so late events cannot redraw stale frames.
suspended: AtomicBool,
started: Instant,
}
/// Live build view for `pkh deb` / `pkh build`
///
/// Create one per build (it disables itself automatically when stdout is not
/// a TTY) and pass it down through the [`crate::report::BuildView`] port.
/// Subprocess output reaches it through [`crate::report::BuildView::sink`].
pub struct DebUi {
shared: Arc<Shared>,
}
impl DebUi {
/// Create a live view attached to `multi`
///
/// When stdout is not a TTY, the widget is disabled: no bars are drawn,
/// but captured output is still tee'd to the log file.
pub fn new(multi: &MultiProgress) -> Self {
let enabled = is_stdout_tty();
let top = if enabled {
let pb = multi.add(ProgressBar::new(0));
pb.enable_steady_tick(Duration::from_millis(80));
pb.set_style(spinner_style());
pb.set_prefix("Building package");
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(&timestamp);
let ui = Self {
shared: Arc::new(Shared {
multi: multi.clone(),
top,
pane,
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(log_path.clone()),
timestamp,
enabled,
suspended: AtomicBool::new(false),
started: Instant::now(),
}),
};
if ui.shared.enabled {
install_sigint_hook(&log_path);
}
ui
}
/// Identify the binary package being built; names the log file and the
/// status bar
pub fn set_target(&self, package: &str, version: &str, series: &str, arch: &str) {
if self.shared.enabled {
self.shared.top.set_prefix(format!(
"Building {package} ({version}) for {series}/{arch}"
));
}
self.open_log("deb", package, version, &format!("for {series}/{arch}"));
}
/// Rename the placeholder log file to include the build identity
/// (best-effort), then open it so subsequent captured lines are tee'd
fn open_log(&self, kind: &str, package: &str, version: &str, detail: &str) {
let old_path = self.shared.log_path.lock().unwrap().clone();
let log_path = match old_path.parent() {
Some(dir) => dir.join(format!(
"{kind}-{package}-{version}-{}.log",
self.shared.timestamp
)),
None => old_path.clone(),
};
let _ = fs::rename(&old_path, &log_path);
*self.shared.log_path.lock().unwrap() = log_path.clone();
update_sigint_log_path(&log_path);
if let Some(dir) = log_path.parent() {
let _ = fs::create_dir_all(dir);
}
match File::create(&log_path) {
Ok(mut file) => {
let _ = writeln!(
file,
"# pkh {kind} {package} ({version}) {detail} started {}",
chrono::Utc::now().to_rfc3339()
);
*self.shared.tee.lock().unwrap() = Some(file);
}
Err(e) => {
log::warn!(
"Could not create build log file {}: {}",
log_path.display(),
e
);
}
}
}
/// Switch to an arbitrary status label with a custom classifier
fn phase_custom(&self, label: &str, classifier: Box<dyn Classifier>) {
{
let mut st = self.shared.state.lock().unwrap();
st.classifier = classifier;
st.lines.clear();
st.bar_total = 0;
st.last_draw = Instant::now();
}
if self.shared.enabled {
self.shared.top.set_style(spinner_style());
self.shared.top.set_message(label.to_string());
drop_pane(&self.shared);
}
}
/// Whether the widget is enabled and still drawn
fn active(&self) -> bool {
self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst)
}
/// Release the widget from the terminal (e.g. before printing
/// passthrough diagnostics or letting child cleanup commands write to
/// the terminal); idempotent
///
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
/// right after the clear, leaving stale copies of the widget on screen.
fn suspend(&self) {
if !self.shared.enabled {
return;
}
if self.shared.suspended.swap(true, Ordering::SeqCst) {
return;
}
self.shared.top.disable_steady_tick();
drop_pane(&self.shared);
self.shared.top.finish_and_clear();
}
/// Success outcome body: clear the widget and print the artifacts,
/// rendered relative to the working directory when possible
fn success_summary(&self, artifacts: &[PathBuf], elapsed: Duration) {
self.suspend();
if self.shared.enabled && !artifacts.is_empty() {
println!("Built in {}s:", elapsed.as_secs());
for artifact in artifacts {
println!(" {}", crate::report::display_path(artifact));
}
}
}
/// Failure outcome body: clear the widget and print a summary (recent
/// captured errors and the path to the full log)
fn failure_summary(&self) {
self.suspend();
let st = self.shared.state.lock().unwrap();
if self.shared.enabled && !st.errors.is_empty() {
eprintln!("Last captured errors:");
for err in st
.errors
.iter()
.rev()
.take(5)
.collect::<Vec<_>>()
.iter()
.rev()
{
eprintln!(" {}", err);
}
}
let log_path = self.shared.log_path.lock().unwrap().clone();
if log_path.exists() {
eprintln!("Full log: {}", log_path.display());
}
}
}
/// [`crate::report::BuildView`] port: forwards build events to the live
/// widget, so core flows drive the view without knowing it is a terminal
/// widget.
impl crate::report::BuildView for DebUi {
fn target(&self, target: BuildTarget<'_>) {
if self.shared.enabled {
self.shared.top.set_prefix(target.display.clone());
}
if target.tee_log {
let kind = if target.source_only { "build" } else { "deb" };
self.open_log(
kind,
target.package,
target.version,
&format!("for {}", target.target),
);
}
}
fn phase(&self, name: &str, classifier: Box<dyn Classifier>) {
self.phase_custom(name, classifier);
}
fn message(&self, text: &str) {
if self.active() {
self.shared.top.set_message(text.to_string());
}
}
fn progress(&self, label: &str, pos: usize, total: usize) {
if !self.active() || total == 0 {
return;
}
apply_progress(
&self.shared.top,
&mut self.shared.state.lock().unwrap(),
pos as u64,
total as u64,
);
self.shared.top.set_message(label.to_string());
}
fn sink(&self) -> Option<Arc<dyn LineSink>> {
Some(Arc::new(Sink {
shared: self.shared.clone(),
}))
}
fn finish_success(&self, artifacts: &[PathBuf]) {
self.success_summary(artifacts, self.shared.started.elapsed());
}
fn finish_failure(&self) {
self.failure_summary();
}
fn suspend(&self) {
DebUi::suspend(self);
}
fn is_enabled(&self) -> bool {
self.shared.enabled
}
}
impl Drop for DebUi {
fn drop(&mut self) {
// Safety net: clear the widget on early returns/unwinds
self.suspend();
}
}
/// Bridge forwarding captured subprocess lines into the pipeline
struct Sink {
shared: Arc<Shared>,
}
impl LineSink for Sink {
fn line(&self, stream: Stream, line: &str) {
// Tee the raw line first: nothing should be lost, even when the
// widget is disabled.
{
let mut tee = self.shared.tee.lock().unwrap();
if let Some(file) = tee.as_mut() {
let _ = writeln!(file, "{line}");
}
}
if !self.shared.enabled || self.shared.suspended.load(Ordering::SeqCst) {
return;
}
let mut st = self.shared.state.lock().unwrap();
for action in st.classifier.feed(stream, line) {
match action {
Action::Hidden => {}
Action::Progress { pos, total } => {
apply_progress(&self.shared.top, &mut st, pos, total)
}
Action::Shown(text) => push_line(&self.shared, &mut st, Kind::Normal, text),
Action::Warning(text) => push_line(&self.shared, &mut st, Kind::Warning, text),
Action::Error(text) => {
if st.errors.len() < 100 {
st.errors.push(text.clone());
}
push_line(&self.shared, &mut st, Kind::Error, text);
}
}
}
}
}
/// Update the determinate progress bar, restyling it when needed
fn apply_progress(top: &ProgressBar, st: &mut Pipeline, pos: u64, total: u64) {
if total == 0 {
return;
}
if st.bar_total != total {
st.bar_total = total;
top.set_style(determinate_style());
top.set_length(total);
}
top.set_position(pos.min(total));
}
/// Push a line into the rolling pane and redraw (throttled)
///
/// When the pane is full, the oldest non-error line is dropped first so
/// errors stay visible longer.
fn push_line(shared: &Shared, st: &mut Pipeline, kind: Kind, text: String) {
st.lines.push_back((kind, text));
while st.lines.len() > PANE_LINES {
let drop_idx = st
.lines
.iter()
.position(|(k, _)| *k != Kind::Error)
.unwrap_or(0);
st.lines.remove(drop_idx);
}
let now = Instant::now();
if now.duration_since(st.last_draw) >= REDRAW_INTERVAL {
st.last_draw = now;
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<ProgressBar> {
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);
}
}
/// Render the pane content with per-kind colors, ellipsizing lines that are
/// wider than the terminal so they do not overflow onto a wrapped line
fn render_pane(lines: &VecDeque<(Kind, String)>) -> String {
let max_width = terminal_width().map(|w| w.saturating_sub(PANE_PREFIX_WIDTH));
render_pane_with_width(lines, max_width)
}
/// [`render_pane`] with the available pane width injected (in display
/// columns); `None` means the terminal size is unknown and lines are kept whole
fn render_pane_with_width(lines: &VecDeque<(Kind, String)>, max_width: Option<usize>) -> String {
lines
.iter()
.map(|(kind, text)| {
let text = match max_width {
Some(width) => ellipsize(text, width),
None => text.clone(),
};
match kind {
Kind::Normal => format!(" │ {text}"),
Kind::Warning => format!(" │ {}", text.as_str().yellow()),
Kind::Error => format!(" │ {}", text.as_str().red()),
}
})
.collect::<Vec<_>>()
.join("\n")
}
/// Display width of the ` │ ` prefix rendered before each pane line
const PANE_PREFIX_WIDTH: usize = 4;
/// Width of the terminal in columns, or `None` when it cannot be determined
fn terminal_width() -> Option<usize> {
crossterm::terminal::size()
.ok()
.map(|(cols, _)| cols as usize)
}
/// Ellipsize `text` to at most `max_width` display columns, keeping its head
/// and appending `…` when it does not fit
fn ellipsize(text: &str, max_width: usize) -> String {
if UnicodeWidthStr::width(text) <= max_width {
return text.to_string();
}
// Reserve one column for the ellipsis itself
let budget = max_width.saturating_sub(1);
let mut out = String::new();
let mut width = 0;
for ch in text.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + w > budget {
break;
}
out.push(ch);
width += w;
}
out.push('…');
out
}
/// Status bar style while no determinate progress is known
///
/// The target lives on the first line and the current phase/message on its own
/// line below, so narrow terminals are not overflowed.
fn spinner_style() -> ProgressStyle {
ProgressStyle::default_bar()
.template("> {spinner:.blue} {prefix}\n {msg}")
.expect("valid template")
}
/// Status bar style with a determinate progress bar
///
/// Same two-line layout as [`spinner_style`], plus the bar on a third line.
fn determinate_style() -> ProgressStyle {
ProgressStyle::default_bar()
.template("> {spinner:.blue} {prefix}\n {msg} [{bar:24.cyan/blue}] {pos}/{len}")
.expect("valid template")
.progress_chars("=> ")
}
/// Whether stdout is a terminal
fn is_stdout_tty() -> bool {
// SAFETY: isatty only inspects the file descriptor
unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
}
/// Default (placeholder) log file path for a given timestamp; renamed by
/// [`DebUi::set_target`] / [`DebUi::set_build_target`] once the target is known
fn default_log_path(timestamp: &str) -> PathBuf {
let dir = ProjectDirs::from("com", "pkh", "pkh")
.map(|dirs| dirs.cache_dir().join("logs"))
.unwrap_or_else(std::env::temp_dir);
dir.join(format!("pkh-{timestamp}.log"))
}
static SIGINT_LOG_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
static SIGINT_INSTALLED: AtomicBool = AtomicBool::new(false);
/// Install a best-effort Ctrl+C handler clearing the widget and pointing at
/// the log file before exiting
fn install_sigint_hook(log_path: &Path) {
update_sigint_log_path(log_path);
if SIGINT_INSTALLED.swap(true, Ordering::SeqCst) {
return;
}
// SAFETY: installing a signal handler; the handler itself is best-effort
// (it performs non async-signal-safe operations, acceptable here because
// it immediately exits afterwards).
unsafe {
libc::signal(libc::SIGINT, on_sigint as *const () as usize);
}
}
/// Point the sigint handler at the current log file location
fn update_sigint_log_path(log_path: &Path) {
*SIGINT_LOG_PATH.lock().unwrap() = Some(log_path.to_path_buf());
}
extern "C" fn on_sigint(_sig: libc::c_int) {
// Best-effort cleanup: clear leftover widget lines and show the cursor
let _ = execute!(
std::io::stdout(),
Clear(ClearType::FromCursorDown),
cursor::Show
);
if let Ok(guard) = SIGINT_LOG_PATH.try_lock()
&& let Some(path) = guard.as_ref()
{
eprintln!("\nInterrupted — full log: {}", path.display());
}
// Run the registered cleanup hooks (currently: unmount and remove the
// ephemeral build chroot, see `deb::ephemeral::sigint_cleanup_chroot`),
// then exit with the conventional 130 status.
//
// Like the terminal restoration above, this is NOT strictly
// async-signal-safe: it locks a mutex, spawns subprocesses and does I/O.
// That is a deliberate tradeoff, no worse than the rest of this handler:
// exiting immediately would skip all destructors and leak the chroot
// together with its bind-mounted /proc and overlay mounts. The hooks are
// self-contained (they only touch stored paths and spawn umount/rm
// directly), so they cannot deadlock on a lock the interrupted thread
// might have held; the hook registry itself is only ever taken with
// try_lock plus a bounded retry for the same reason. Note that SIGINT
// stays blocked for the duration of the handler, so a second Ctrl-C will
// not interrupt a slow cleanup — send SIGTERM/SIGKILL if it ever hangs.
crate::deb::ephemeral::run_cleanup_hooks();
// SAFETY: raw exit bypassing destructors, intended in a signal handler
unsafe {
libc::_exit(130);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ellipsize_keeps_short_lines() {
assert_eq!(ellipsize("short", 10), "short");
assert_eq!(ellipsize("exactly10!", 10), "exactly10!");
}
#[test]
fn ellipsize_truncates_long_lines_to_the_width_budget() {
let out = ellipsize("a very long build line that overflows", 20);
assert_eq!(UnicodeWidthStr::width(out.as_str()), 20);
assert!(out.ends_with('…'));
assert!(out.starts_with("a very long build"));
}
#[test]
fn ellipsize_never_exceeds_the_budget_with_wide_characters() {
let out = ellipsize("wíth émojis 🎉 and 文字 mixing", 12);
assert!(UnicodeWidthStr::width(out.as_str()) <= 12);
assert!(out.ends_with('…'));
}
#[test]
fn ellipsize_degenerate_width_still_terminates() {
assert_eq!(ellipsize("overflowing", 0), "…");
assert_eq!(ellipsize("overflowing", 1), "…");
}
#[test]
fn pane_lines_are_ellipsized_but_keep_their_prefix_and_color() {
let mut lines = VecDeque::new();
lines.push_back((
Kind::Normal,
"gcc -DHAVE_CONFIG_H -I. -I.. -g -O2 -c hello.c".to_string(),
));
lines.push_back((
Kind::Error,
"an error much too long for the pane".to_string(),
));
let rendered = render_pane_with_width(&lines, Some(20));
let rendered = rendered.lines().collect::<Vec<_>>();
assert_eq!(rendered.len(), 2);
// The injected budget is the text width; every rendered line stays
// within the simulated terminal width (prefix + budget)
for line in &rendered {
let plain = strip_ansi(line);
assert!(
UnicodeWidthStr::width(plain.as_str()) <= 20 + PANE_PREFIX_WIDTH,
"{plain}"
);
}
assert!(strip_ansi(rendered[0]).starts_with(" │ gcc -DHAVE_CONFIG_H"));
// The error keeps its color wrapping around the ellipsized text
assert!(rendered[1].contains('\x1b'), "{rendered:?}");
assert!(strip_ansi(rendered[1]).starts_with(" │ an error much too l…"));
}
#[test]
fn pane_lines_are_kept_whole_without_a_known_terminal_size() {
let mut lines = VecDeque::new();
lines.push_back((
Kind::Normal,
"a line that would overflow a narrow pane".to_string(),
));
let rendered = render_pane_with_width(&lines, None);
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();
let mut chars = line.chars();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
for esc in chars.by_ref() {
if esc.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(ch);
}
}
out
}
}