Files
pkh/src/ui/deb.rs
T
vhaudiquet e5adf600c3
CI / build (push) Successful in 2m54s
CI / test (push) Skipped
CI / snap (push) Failing after 12s
deb: change ui/ux of pkh deb
2026-08-22 22:26:20 +02:00

546 lines
18 KiB
Rust

//! Live UI for `pkh deb`: 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::ui::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 crate::context::{LineSink, Stream};
use crate::ui::logfmt::{
Action, AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier,
MakeClassifier, MmdebstrapClassifier, QuiltClassifier,
};
/// Number of lines displayed in the rolling pane
const PANE_LINES: usize = 4;
/// Minimum interval between pane redraws
const REDRAW_INTERVAL: Duration = Duration::from_millis(50);
/// Build phases of `pkh deb`, shown in the status bar
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
/// Downloading the chroot tarball (mmdebstrap)
PreparingChroot,
/// Extracting the chroot tarball
ExtractingChroot,
/// Device nodes, /proc bind mount, etc.
FinalizingChroot,
/// apt-get update
UpdatingPackageLists,
/// Installing build-essential & co
InstallingEssentials,
/// quilt push -a
ApplyingPatches,
/// --inject packages
InjectingPackages,
/// apt-get build-dep
InstallingBuildDeps,
/// debian/rules build
Building,
/// fakeroot debian/rules binary
ProducingBinaries,
/// Retrieving produced .deb files
RetrievingArtifacts,
}
impl Phase {
/// Human-readable label displayed in the status bar
pub fn label(&self) -> &'static str {
match self {
Phase::PreparingChroot => "Preparing chroot",
Phase::ExtractingChroot => "Extracting chroot",
Phase::FinalizingChroot => "Finalizing chroot",
Phase::UpdatingPackageLists => "Updating package lists",
Phase::InstallingEssentials => "Installing essential packages",
Phase::ApplyingPatches => "Applying patches",
Phase::InjectingPackages => "Injecting packages",
Phase::InstallingBuildDeps => "Installing build dependencies",
Phase::Building => "Building package",
Phase::ProducingBinaries => "Producing binary packages",
Phase::RetrievingArtifacts => "Retrieving artifacts",
}
}
}
/// Default classifier used for a given phase
fn default_classifier(phase: Phase) -> Box<dyn Classifier> {
match phase {
Phase::PreparingChroot => Box::new(MmdebstrapClassifier::new()),
Phase::ExtractingChroot | Phase::FinalizingChroot => Box::new(GenericClassifier::new()),
Phase::UpdatingPackageLists => Box::new(AptUpdateClassifier::new()),
Phase::InstallingEssentials => Box::new(AptInstallClassifier::new("Installing essentials")),
Phase::ApplyingPatches => Box::new(QuiltClassifier::new(0)),
Phase::InjectingPackages => Box::new(AptInstallClassifier::new("Injecting packages")),
Phase::InstallingBuildDeps => {
Box::new(AptInstallClassifier::new("Installing build dependencies"))
}
Phase::Building | Phase::ProducingBinaries => Box::new(MakeClassifier::new()),
Phase::RetrievingArtifacts => Box::new(GenericClassifier::new()),
}
}
/// 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 {
top: ProgressBar,
pane: 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`
///
/// Create one per build (disabled automatically when stdout is not a TTY or
/// when the user requests verbose output), pass it down as
/// `Option<Arc<DebUi>>`, and feed subprocess output through [`DebUi::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.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 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 {
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 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}"
));
}
// Rename the log file to include the package identity (best-effort),
// then open it so subsequent captured lines are tee'd.
let old_path = self.shared.log_path.lock().unwrap().clone();
let log_path = match old_path.parent() {
Some(dir) => dir.join(format!(
"deb-{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 deb {} ({}) for {}/{} started {}",
package,
version,
series,
arch,
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 a phase, installing its default classifier
pub fn phase(&self, phase: Phase) {
self.phase_with(phase, default_classifier(phase));
}
/// Switch to a phase with a custom classifier (e.g. quilt with a known
/// patch count)
pub fn phase_with(&self, phase: Phase, 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(phase.label());
self.shared.pane.set_message("");
}
}
/// Update the status bar message directly (for in-process work such as
/// tarball extraction that has no subprocess output)
pub fn progress_message(&self, msg: &str) {
if self.active() {
self.shared.top.set_message(msg.to_string());
}
}
/// Drive the determinate progress bar directly (e.g. artifact retrieval)
pub fn count_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());
}
/// Whether the widget is enabled and still drawn
fn active(&self) -> bool {
self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst)
}
/// Obtain a sink feeding this view; pass it to `ContextCommand::capture`
pub fn sink(self: &Arc<Self>) -> Arc<dyn LineSink> {
Arc::new(Sink {
shared: self.shared.clone(),
})
}
/// Remove 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.
pub fn suspend(&self) {
if !self.shared.enabled {
return;
}
if self.shared.suspended.swap(true, Ordering::SeqCst) {
return;
}
self.shared.top.disable_steady_tick();
self.shared.pane.disable_steady_tick();
self.shared.top.finish_and_clear();
self.shared.pane.finish_and_clear();
}
/// Clear the widget and print a success summary with the artifacts
pub fn finish_success(&self, artifacts: &[PathBuf], elapsed: Duration) {
self.suspend();
if self.shared.enabled && !artifacts.is_empty() {
for artifact in artifacts {
println!(" → {}", artifact.display());
}
println!(" ✔ Built in {}s", elapsed.as_secs());
}
}
/// Clear the widget and print a failure summary (recent captured errors
/// and the path to the full log)
pub fn finish_failure(&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());
}
}
/// Time elapsed since the view was created
pub fn elapsed(&self) -> Duration {
self.shared.started.elapsed()
}
/// Path of the full build log file
pub fn log_path(&self) -> PathBuf {
self.shared.log_path.lock().unwrap().clone()
}
}
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;
shared.pane.set_message(render_pane(&st.lines));
}
}
/// Render the pane content with per-kind colors
fn render_pane(lines: &VecDeque<(Kind, String)>) -> String {
lines
.iter()
.map(|(kind, text)| match kind {
Kind::Normal => format!(" │ {text}"),
Kind::Warning => format!(" │ {}", text.as_str().yellow()),
Kind::Error => format!(" │ {}", text.as_str().red()),
})
.collect::<Vec<_>>()
.join("\n")
}
/// 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 log file path for a given timestamp
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!("deb-{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());
}
// SAFETY: raw exit bypassing destructors, intended in a signal handler
unsafe {
libc::_exit(130);
}
}