The ephemeral guard registers its chroot removal as an interrupt cleanup hook and, once interrupted, stands down from its own teardown so the two cannot race umount/rm; bootstrap bails out of tarball extraction and the lockfile wait, keeping the hook registered on the bootstrap error path so the watchdog can remove the partial tree. The live view registers an interrupt reporter that suspends the widget and returns the log-file hint, silences the tty rendering of the ^C keypress (the echoed "^C" can wrap near the right edge and shift the teardown erase by a row, leaving the first widget line on screen) and kills the shared draw target so late log records cannot repaint the cleared bars. Failure summaries stay quiet when interrupted: the captured errors are just the killed children's death throes, and the dose-builddebcheck diagnosis is skipped for a dependency failure the user interrupted themselves.
796 lines
27 KiB
Rust
796 lines
27 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::PathBuf;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use crossterm::style::Stylize;
|
|
use directories::ProjectDirs;
|
|
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, 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 {
|
|
// The tty renders a ^C keypress as the two visible characters
|
|
// "^C"; on a terminal where the cursor sits near the right edge
|
|
// that wraps to the next row, and the erase at teardown —
|
|
// anchored to where indicatif last drew — ends up one row off,
|
|
// leaving the first widget line on screen. Rendering control
|
|
// characters raw instead (ECHOCTL off) makes the echo an
|
|
// invisible byte that moves nothing; ECHO itself stays on, so
|
|
// terminals showing a padlock while input is hidden are not
|
|
// triggered. Restored by `suspend_shared`.
|
|
suppress_control_char_echo();
|
|
multi.set_draw_target(ProgressDrawTarget::stderr());
|
|
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(×tamp);
|
|
|
|
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),
|
|
timestamp,
|
|
enabled,
|
|
suspended: AtomicBool::new(false),
|
|
started: Instant::now(),
|
|
}),
|
|
};
|
|
|
|
// Ctrl+C: the signal wiring lives in the CLI; this view only
|
|
// registers with `crate::interrupt` how to clear itself and where
|
|
// the full log lives. The log path is read from the shared state at
|
|
// interrupt time, so the rename in `open_log` stays visible to the
|
|
// reporter.
|
|
if ui.shared.enabled {
|
|
set_interrupt_reporter(ui.shared.clone());
|
|
}
|
|
|
|
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.active() {
|
|
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();
|
|
|
|
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.active() {
|
|
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
|
|
fn suspend(&self) {
|
|
suspend_shared(&self.shared);
|
|
}
|
|
|
|
/// 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)
|
|
///
|
|
/// On a Ctrl+C the interrupt watchdog owns the reporting — its captured
|
|
/// errors are just the killed children's death throes, and the watchdog
|
|
/// already points at the full log — so this prints nothing.
|
|
fn failure_summary(&self) {
|
|
self.suspend();
|
|
|
|
if crate::interrupt::interrupted() {
|
|
return;
|
|
}
|
|
|
|
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.active() {
|
|
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"))
|
|
}
|
|
|
|
/// Register the interrupt reporter: release the widget from the terminal
|
|
/// and return the log-file hint to print below the notice
|
|
///
|
|
/// The CLI watchdog runs this as the first step of the interrupt shutdown,
|
|
/// so the widget disappears the moment Ctrl+C is hit. Registered only for
|
|
/// enabled views: in `--verbose` mode or with piped output there is no
|
|
/// widget and nothing to report.
|
|
fn set_interrupt_reporter(shared: Arc<Shared>) {
|
|
crate::interrupt::set_reporter(Box::new(move || interrupt_report(&shared)));
|
|
}
|
|
|
|
/// [`suspend_shared`] plus the log-file hint, in teardown order
|
|
///
|
|
/// Testable end to end: the reporter closure is private to the interrupt
|
|
/// watchdog, but the drawing behavior is not.
|
|
fn interrupt_report(shared: &Shared) -> Option<String> {
|
|
suspend_shared(shared);
|
|
let log_path = shared.log_path.lock().unwrap().clone();
|
|
if log_path.exists() {
|
|
Some(format!("Full log: {}", log_path.display()))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// [`DebUi::suspend`] body, shared with the interrupt reporter
|
|
///
|
|
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
|
|
/// right after the clear, leaving stale copies of the widget on screen.
|
|
/// The tty echo suppressed at view start is restored here, before the
|
|
/// erases: an echo from a keypress landing mid-teardown could not shift the
|
|
/// cursor anymore. The draw target is finally killed: the interrupted flow
|
|
/// keeps emitting log records while the cleanup hooks run, and every one of
|
|
/// them would otherwise make the log bridge repaint the cleared bars from
|
|
/// their cached frames.
|
|
fn suspend_shared(shared: &Shared) {
|
|
if !shared.enabled {
|
|
return;
|
|
}
|
|
if shared.suspended.swap(true, Ordering::SeqCst) {
|
|
return;
|
|
}
|
|
restore_tty_echo();
|
|
shared.top.disable_steady_tick();
|
|
drop_pane(shared);
|
|
shared.top.finish_and_clear();
|
|
shared.multi.set_draw_target(ProgressDrawTarget::hidden());
|
|
}
|
|
|
|
/// Termios snapshot taken when the echo is suppressed; `Some` only while the
|
|
/// live view is on screen
|
|
static SAVED_TTY_TERMIOS: Mutex<Option<libc::termios>> = Mutex::new(None);
|
|
|
|
/// Stop the tty from rendering control-character input (^C would show as a
|
|
/// visible two-character "^C") while the live view is up: rendered echoes
|
|
/// move the cursor without indicatif knowing, and the teardown erase ends
|
|
/// up aimed past the widget
|
|
///
|
|
/// ECHO itself stays on — turning it off would trigger terminals'
|
|
/// hidden-input padlock — so the only visible difference is that a ^C
|
|
/// keypress echoes as a raw, cursor-invisible control byte. No-op without a
|
|
/// tty on stdin.
|
|
fn suppress_control_char_echo() {
|
|
// SAFETY: tcgetattr on stdin with a valid, zero-initialized buffer
|
|
let mut termios: libc::termios = unsafe { std::mem::zeroed() };
|
|
// SAFETY: reading the current attributes of stdin
|
|
if unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut termios) } != 0 {
|
|
return;
|
|
}
|
|
*SAVED_TTY_TERMIOS.lock().unwrap() = Some(termios);
|
|
termios.c_lflag &= !libc::ECHOCTL;
|
|
// SAFETY: applying the modified attributes to stdin
|
|
unsafe {
|
|
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
|
|
}
|
|
}
|
|
|
|
/// Restore the tty attributes saved by [`suppress_tty_echo`]
|
|
fn restore_tty_echo() {
|
|
if let Some(termios) = SAVED_TTY_TERMIOS.lock().unwrap().take() {
|
|
// SAFETY: re-applying the snapshot taken at view start
|
|
unsafe {
|
|
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
/// The interrupt report kills the shared draw target: during the cleanup
|
|
/// hooks the interrupted flow keeps emitting log records, and every one
|
|
/// of them would otherwise make the log bridge repaint the bars from
|
|
/// their cached frames — resurrecting the widget that was just cleared.
|
|
#[test]
|
|
fn interrupt_report_disables_further_redraws() {
|
|
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-interrupt-report-test.log")),
|
|
timestamp: String::new(),
|
|
enabled: true,
|
|
suspended: AtomicBool::new(false),
|
|
started: Instant::now(),
|
|
};
|
|
|
|
interrupt_report(&shared);
|
|
|
|
assert!(shared.suspended.load(Ordering::SeqCst));
|
|
assert!(shared.multi.is_hidden());
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|