use std::{
fs::File,
io::{Read, Seek, SeekFrom},
path::PathBuf,
sync::mpsc::{self, Receiver, Sender},
thread,
time::{Duration, Instant},
};
use crossterm::{
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend, layout::Rect};
use crate::{
ansi::TerminalBuffer,
runner::{BuildJob, BuildPlan},
ui::BuildScreen,
};
pub(crate) const LOG_READ_MAX: usize = 64 * 1024;
pub(crate) const LOG_TICK_MAX: usize = 512 * 1024;
const IDLE_MS: u64 = 16;
pub struct MxrunApp {
plan: BuildPlan,
states: Vec<JobState>,
wrap_lines: bool,
label: Option<String>,
}
impl MxrunApp {
pub fn new(plan: BuildPlan, wrap_lines: bool, label: Option<&str>) -> Self {
Self {
states: plan.jobs().iter().map(JobState::from_job).collect(),
plan,
wrap_lines,
label: label.map(String::from),
}
}
pub fn run(&mut self) -> Result<i32, String> {
TerminalGuard::enter().and_then(|mut terminal| {
JobSupervisor::new(&self.plan).spawn().and_then(|events| {
InputReader::spawn().and_then(|keys| {
AppLoop::new(
&mut self.states,
events,
keys,
terminal.terminal_mut(),
self.wrap_lines,
self.label.as_deref(),
)
.run()
})
})
})
}
}
struct AppLoop<'a> {
states: &'a mut [JobState],
events: Receiver<JobEvent>,
keys: Receiver<KeyPress>,
terminal: &'a mut Terminal<CrosstermBackend<std::io::Stdout>>,
active_pane: usize,
scrollbacks: Vec<usize>,
popup: Option<PopupState>,
popup_dismissed: bool,
wrap_lines: bool,
label: Option<String>,
excuse: Option<String>,
last_load_update: Instant,
}
impl<'a> AppLoop<'a> {
fn new(
states: &'a mut [JobState],
events: Receiver<JobEvent>,
keys: Receiver<KeyPress>,
terminal: &'a mut Terminal<CrosstermBackend<std::io::Stdout>>,
wrap_lines: bool,
label: Option<&str>,
) -> Self {
let pane_count = states.len();
Self {
states,
events,
keys,
terminal,
active_pane: 0,
scrollbacks: vec![0; pane_count],
popup: None,
popup_dismissed: false,
wrap_lines,
label: label.map(String::from),
excuse: None,
last_load_update: Instant::now(),
}
}
fn run(&mut self) -> Result<i32, String> {
loop {
let busy = self.drain_events();
let busy = self.refresh_logs() || busy;
self.refresh_load();
self.refresh_popup();
self.render()?;
if let Some(key) = self.key_pressed()
&& self.handle_key(key)
{
return Ok(self.exit_code_or_abort(key));
}
if !busy {
thread::sleep(Duration::from_millis(IDLE_MS));
}
}
}
fn drain_events(&mut self) -> bool {
let mut busy = false;
while let Ok(event) = self.events.try_recv() {
self.states[event.index].apply(event);
busy = true;
}
busy
}
fn refresh_logs(&mut self) -> bool {
let mut busy = false;
for st in self.states.iter_mut() {
busy = st.refresh_log() || busy;
}
busy
}
fn refresh_load(&mut self) {
if self.last_load_update.elapsed() >= Duration::from_secs(1) {
self.states.iter_mut().for_each(|st| st.update_load());
self.last_load_update = Instant::now();
}
}
fn render(&mut self) -> Result<(), String> {
self.terminal
.draw(|frame| {
BuildScreen::from_states(
self.states,
self.active_pane,
&self.scrollbacks,
self.popup,
self.wrap_lines,
self.label.as_deref(),
self.excuse.as_deref(),
)
.render(frame)
})
.map_err(|err| format!("mxrun: failed to render TUI: {err}"))
.map(|_| ())
}
fn refresh_popup(&mut self) {
if self.all_finished() && !self.popup_dismissed {
self.popup = Some(PopupState::Finished);
if self.excuse.is_none() && self.states.iter().any(JobState::is_failed) {
self.excuse = Some(crate::excuses::random_excuse().to_string());
}
}
}
fn all_finished(&self) -> bool {
self.states.iter().all(JobState::is_finished)
}
fn exit_code(&self) -> i32 {
self.states
.iter()
.find(|state| !state.is_success())
.map(|state| state.status_code())
.unwrap_or(0)
}
fn exit_code_or_abort(&self, key: KeyPress) -> i32 {
if !self.all_finished() && key.should_abort_confirmed() {
130
} else {
self.exit_code()
}
}
fn key_pressed(&self) -> Option<KeyPress> {
self.keys.try_recv().ok()
}
fn handle_key(&mut self, key: KeyPress) -> bool {
if !self.all_finished() {
return self.handle_live_key(key);
}
if self.popup.is_some() {
if key.should_quit_finished() {
return true;
}
self.popup = None;
self.popup_dismissed = true;
return false;
}
self.handle_finished_key(key)
}
fn handle_live_key(&mut self, key: KeyPress) -> bool {
if self.popup == Some(PopupState::AbortConfirm) {
if key.should_abort_confirmed() {
return true;
}
self.popup = None;
return false;
}
if key.should_abort_requested() {
self.popup = Some(PopupState::AbortConfirm);
return false;
}
if key.should_quit_live() {
return true;
}
key.navigation()
.into_iter()
.for_each(|direction| self.move_active_pane(direction));
key.scroll()
.into_iter()
.for_each(|scroll| self.scroll_active_pane(scroll));
false
}
fn handle_finished_key(&mut self, key: KeyPress) -> bool {
if key.should_quit_finished() {
return true;
}
key.navigation()
.into_iter()
.for_each(|direction| self.move_active_pane(direction));
key.scroll()
.into_iter()
.for_each(|scroll| self.scroll_active_pane(scroll));
false
}
fn move_active_pane(&mut self, direction: PaneDirection) {
if self.states.is_empty() {
return;
}
self.active_pane = direction.next_index(self.active_pane, self.states.len());
}
fn scroll_active_pane(&mut self, scroll: PaneScroll) {
let page_height = self.active_viewport_height();
self.scrollbacks
.get_mut(self.active_pane)
.into_iter()
.for_each(|scrollback| *scrollback = scroll.next_offset(*scrollback, page_height));
}
fn active_viewport_height(&self) -> usize {
self.terminal
.size()
.ok()
.map(|size| {
BuildScreen::viewport_height(
self.states.len(),
self.active_pane,
Rect::new(0, 0, size.width, size.height),
)
})
.unwrap_or(10)
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum PopupState {
Finished,
AbortConfirm,
}
#[derive(Clone, Copy)]
pub(crate) struct KeyPress {
code: KeyCode,
modifiers: KeyModifiers,
}
impl KeyPress {
fn from_event(evt: Event) -> Option<Self> {
match evt {
Event::Key(key) => Some(Self::from_key(key)),
_ => None,
}
}
pub(crate) fn from_key(key: KeyEvent) -> Self {
Self {
code: key.code,
modifiers: key.modifiers,
}
}
fn should_quit_live(&self) -> bool {
self.is_escape()
}
fn should_abort_requested(&self) -> bool {
self.is_ctrl_c() || self.is_any_quit_char()
}
fn should_abort_confirmed(&self) -> bool {
self.is_ctrl_c() || self.is_abort_yes()
}
pub(crate) fn should_quit_finished(&self) -> bool {
self.is_ctrl_c() || self.is_any_quit_char()
}
fn navigation(&self) -> Option<PaneDirection> {
match self.code {
KeyCode::BackTab => Some(PaneDirection::Prev),
KeyCode::Tab => Some(PaneDirection::Next),
_ => None,
}
}
fn scroll(&self) -> Option<PaneScroll> {
match self.code {
KeyCode::Up => Some(PaneScroll::LineUp),
KeyCode::Down => Some(PaneScroll::LineDown),
KeyCode::PageUp if self.modifiers.contains(KeyModifiers::SHIFT) => {
Some(PaneScroll::Top)
}
KeyCode::PageDown if self.modifiers.contains(KeyModifiers::SHIFT) => {
Some(PaneScroll::Bottom)
}
KeyCode::Home => Some(PaneScroll::Top),
KeyCode::End => Some(PaneScroll::Bottom),
KeyCode::PageUp => Some(PaneScroll::PageUp),
KeyCode::PageDown => Some(PaneScroll::PageDown),
_ => None,
}
}
fn is_ctrl_c(&self) -> bool {
self.code == KeyCode::Char('c') && self.modifiers.contains(KeyModifiers::CONTROL)
}
fn is_escape(&self) -> bool {
self.code == KeyCode::Esc
}
fn is_any_quit_char(&self) -> bool {
match self.code {
KeyCode::Char(ch) => ch.eq_ignore_ascii_case(&'q'),
_ => false,
}
}
fn is_abort_yes(&self) -> bool {
matches!(self.code, KeyCode::Char('y') | KeyCode::Char('Y'))
}
}
#[derive(Clone, Copy)]
enum PaneDirection {
Prev,
Next,
}
impl PaneDirection {
fn next_index(&self, active: usize, count: usize) -> usize {
if count == 0 {
return 0;
}
match self {
Self::Prev => active.checked_sub(1).unwrap_or(count - 1),
Self::Next => (active + 1) % count,
}
}
}
#[derive(Clone, Copy)]
enum PaneScroll {
LineUp,
LineDown,
PageUp,
PageDown,
Top,
Bottom,
}
impl PaneScroll {
fn next_offset(&self, current: usize, page_height: usize) -> usize {
match self {
Self::LineUp => current.saturating_add(1),
Self::LineDown => current.saturating_sub(1),
Self::PageUp => current.saturating_add(page_height.max(1)),
Self::PageDown => current.saturating_sub(page_height.max(1)),
Self::Top => usize::MAX,
Self::Bottom => 0,
}
}
}
struct JobSupervisor<'a> {
plan: &'a BuildPlan,
}
struct InputReader;
impl InputReader {
fn spawn() -> Result<Receiver<KeyPress>, String> {
let (tx, rx) = mpsc::channel();
thread::Builder::new()
.name("mxrun-input".to_string())
.spawn(move || Self::run(tx))
.map_err(|err| format!("mxrun: failed to start input thread: {err}"))?;
Ok(rx)
}
fn run(tx: Sender<KeyPress>) {
loop {
let event = event::read();
if let Ok(evt) = event
&& let Some(key) = KeyPress::from_event(evt)
&& tx.send(key).is_err()
{
return;
}
}
}
}
impl<'a> JobSupervisor<'a> {
fn new(plan: &'a BuildPlan) -> Self {
Self { plan }
}
fn spawn(&self) -> Result<Receiver<JobEvent>, String> {
let (tx, rx) = mpsc::channel();
self.plan
.jobs()
.iter()
.cloned()
.enumerate()
.for_each(|(index, job)| JobWorker::new(index, job, tx.clone()).spawn());
Ok(rx)
}
}
struct JobWorker {
index: usize,
job: BuildJob,
tx: Sender<JobEvent>,
}
impl JobWorker {
fn new(index: usize, job: BuildJob, tx: Sender<JobEvent>) -> Self {
Self { index, job, tx }
}
fn spawn(self) {
thread::spawn(move || {
let _ = self.tx.send(JobEvent::building(self.index));
if self.job.is_init() {
let _ = self.tx.send(
self.job
.prepare()
.map(|_| JobEvent::finished(self.index, 0))
.unwrap_or_else(|err| JobEvent::failed(self.index, err)),
);
return;
}
let _ = self.tx.send(
self.job
.prepare()
.and_then(|_| self.job.run_build())
.and_then(|status| {
if status == 0 && self.job.should_mirror_results() {
self.tx
.send(JobEvent::mirroring(self.index))
.map_err(|err| err.to_string())
.map(|_| status)
} else {
Ok(status)
}
})
.and_then(|status| {
if status == 0 {
self.job.run_mirror().map(|_| status)
} else {
Ok(status)
}
})
.map(|status| JobEvent::finished(self.index, status))
.unwrap_or_else(|err| JobEvent::failed(self.index, err)),
);
});
}
}
#[derive(Clone)]
pub struct JobState {
title: String,
log_path: PathBuf,
log_buffer: TerminalBuffer,
rendered_lines: Vec<ratatui::text::Line<'static>>,
log_offset: u64,
stage: JobStage,
done: Option<JobStage>,
status_code: i32,
load_info: String,
}
impl JobState {
pub(crate) fn from_job(job: &BuildJob) -> Self {
Self {
title: job.target().title(),
log_path: job.log_path().to_path_buf(),
log_buffer: TerminalBuffer::new(),
rendered_lines: Vec::new(),
log_offset: 0,
stage: JobStage::Pending,
done: None,
status_code: 0,
load_info: String::new(),
}
}
pub(crate) fn apply(&mut self, event: JobEvent) {
self.status_code = event.status_code;
if let Some(message) = event.error {
let dirty = self.log_buffer.push_text(&format!("\n{message}\n"));
self.rendered_lines.truncate(dirty);
self.rendered_lines
.extend(self.log_buffer.lines_from(dirty));
}
if event.stage.is_finished() {
self.done = Some(event.stage);
self.finish_if_drained();
return;
}
self.done = None;
self.stage = event.stage;
}
pub(crate) fn refresh_log(&mut self) -> bool {
if !self.log_path.exists() {
self.finish_if_drained();
return false;
}
let mut busy = false;
let mut total = 0;
let mut dirty = self.rendered_lines.len();
while total < LOG_TICK_MAX {
let Some(bytes) = self
.read_new_log_bytes((LOG_TICK_MAX - total).min(LOG_READ_MAX))
.ok()
.filter(|bytes| !bytes.is_empty())
else {
break;
};
total += bytes.len();
dirty = dirty.min(
self.log_buffer
.push_text(String::from_utf8_lossy(&bytes).as_ref()),
);
busy = true;
if bytes.len() < LOG_READ_MAX {
break;
}
}
if busy {
self.rendered_lines.truncate(dirty);
self.rendered_lines
.extend(self.log_buffer.lines_from(dirty));
}
self.finish_if_drained();
busy
}
fn read_new_log_bytes(&mut self, max: usize) -> Result<Vec<u8>, String> {
File::open(&self.log_path)
.map_err(|err| format!("mxrun: failed to open log file: {err}"))
.and_then(|mut file| {
file.seek(SeekFrom::Start(self.log_offset))
.map_err(|err| format!("mxrun: failed to seek log file: {err}"))?;
let mut bytes = vec![0_u8; max];
let read_len = file
.read(&mut bytes)
.map_err(|err| format!("mxrun: failed to read log file: {err}"))?;
bytes.truncate(read_len);
self.log_offset = self
.log_offset
.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
Ok(bytes)
})
}
pub fn title(&self) -> &str {
&self.title
}
pub fn log_lines(&self) -> &[ratatui::text::Line<'static>] {
&self.rendered_lines
}
pub fn summary(&self) -> &str {
self.stage.label()
}
pub fn stage(&self) -> JobStage {
self.stage
}
pub fn load_info(&self) -> &str {
&self.load_info
}
pub fn is_finished(&self) -> bool {
self.stage.is_finished()
}
pub fn is_failed(&self) -> bool {
matches!(self.stage, JobStage::Failed)
}
pub fn is_success(&self) -> bool {
self.stage.is_success()
}
pub fn status_code(&self) -> i32 {
self.status_code
}
pub(crate) fn update_load(&mut self) {
if matches!(self.stage, JobStage::Building) {
self.load_info = system_load();
} else {
self.load_info.clear();
}
}
fn finish_if_drained(&mut self) {
if self.done.is_some() && self.log_drained() {
self.stage = self.done.take().unwrap_or(self.stage);
}
}
fn log_drained(&self) -> bool {
std::fs::metadata(&self.log_path)
.map(|meta| meta.len() <= self.log_offset)
.unwrap_or(true)
}
}
#[derive(Clone, Copy)]
pub enum JobStage {
Pending,
Building,
Mirroring,
Success,
Failed,
}
impl JobStage {
pub(crate) fn label(&self) -> &str {
match self {
Self::Pending => "pending",
Self::Building => "building",
Self::Mirroring => "mirroring",
Self::Success => "finished",
Self::Failed => "failed",
}
}
fn is_finished(&self) -> bool {
matches!(self, Self::Success | Self::Failed)
}
fn is_success(&self) -> bool {
matches!(self, Self::Success)
}
}
fn system_load() -> String {
std::fs::read_to_string("/proc/loadavg")
.ok()
.and_then(|s| s.split_whitespace().next().map(|v| format!("CPU: {}", v)))
.unwrap_or_default()
}
pub(crate) struct JobEvent {
index: usize,
stage: JobStage,
status_code: i32,
error: Option<String>,
}
impl JobEvent {
pub(crate) fn building(index: usize) -> Self {
Self {
index,
stage: JobStage::Building,
status_code: 0,
error: None,
}
}
pub(crate) fn mirroring(index: usize) -> Self {
Self {
index,
stage: JobStage::Mirroring,
status_code: 0,
error: None,
}
}
pub(crate) fn finished(index: usize, status_code: i32) -> Self {
Self {
index,
stage: if status_code == 0 {
JobStage::Success
} else {
JobStage::Failed
},
status_code,
error: None,
}
}
pub(crate) fn failed(index: usize, error: String) -> Self {
Self {
index,
stage: JobStage::Failed,
status_code: 1,
error: Some(error),
}
}
}
struct TerminalGuard {
terminal: Terminal<CrosstermBackend<std::io::Stdout>>,
}
impl TerminalGuard {
fn enter() -> Result<Self, String> {
enable_raw_mode().map_err(|err| format!("mxrun: failed to enable raw mode: {err}"))?;
execute!(std::io::stdout(), EnterAlternateScreen)
.map_err(|err| format!("mxrun: failed to enter alternate screen: {err}"))?;
Terminal::new(CrosstermBackend::new(std::io::stdout()))
.map_err(|err| format!("mxrun: failed to create terminal: {err}"))
.map(|terminal| Self { terminal })
}
fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<std::io::Stdout>> {
&mut self.terminal
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
}
}