use std::io::{self, Stdout};
use std::sync::mpsc::{Receiver, TryRecvError};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::prelude::*;
use ratatui::Terminal;
use zlayer_tui::terminal::{restore_terminal, setup_terminal, POLL_DURATION};
use zlayer_tui::widgets::scrollable_pane::OutputLine;
use super::build_view::BuildView;
use super::{BuildEvent, InstructionStatus};
pub struct BuildTui {
event_rx: Receiver<BuildEvent>,
state: BuildState,
running: bool,
}
#[derive(Debug, Default)]
pub struct BuildState {
pub stages: Vec<StageState>,
pub current_stage: usize,
pub current_instruction: usize,
pub output_lines: Vec<OutputLine>,
pub scroll_offset: usize,
pub completed: bool,
pub error: Option<String>,
pub image_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct StageState {
pub index: usize,
pub name: Option<String>,
pub base_image: String,
pub instructions: Vec<InstructionState>,
pub complete: bool,
}
#[derive(Debug, Clone)]
pub struct InstructionState {
pub text: String,
pub status: InstructionStatus,
}
impl BuildTui {
#[must_use]
pub fn new(event_rx: Receiver<BuildEvent>) -> Self {
Self {
event_rx,
state: BuildState::default(),
running: true,
}
}
pub fn run(&mut self) -> io::Result<()> {
let mut terminal = setup_terminal()?;
let result = self.run_loop(&mut terminal);
restore_terminal(&mut terminal)?;
result
}
fn run_loop(&mut self, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
while self.running {
self.process_events();
terminal.draw(|frame| self.render(frame))?;
if self.state.completed {
break;
}
if event::poll(POLL_DURATION)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
self.handle_input(key.code, key.modifiers);
}
}
}
}
Ok(())
}
fn process_events(&mut self) {
loop {
match self.event_rx.try_recv() {
Ok(event) => self.handle_build_event(event),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
if !self.state.completed {
self.state.completed = true;
if self.state.error.is_none() && self.state.image_id.is_none() {
self.state.error = Some("Build ended unexpectedly".to_string());
}
}
break;
}
}
}
}
fn handle_build_event(&mut self, event: BuildEvent) {
match event {
BuildEvent::StageStarted {
index,
name,
base_image,
} => {
while self.state.stages.len() <= index {
self.state.stages.push(StageState {
index: self.state.stages.len(),
name: None,
base_image: String::new(),
instructions: Vec::new(),
complete: false,
});
}
self.state.stages[index] = StageState {
index,
name,
base_image,
instructions: Vec::new(),
complete: false,
};
self.state.current_stage = index;
self.state.current_instruction = 0;
}
BuildEvent::InstructionStarted {
stage,
index,
instruction,
} => {
if let Some(stage_state) = self.state.stages.get_mut(stage) {
while stage_state.instructions.len() <= index {
stage_state.instructions.push(InstructionState {
text: String::new(),
status: InstructionStatus::Pending,
});
}
stage_state.instructions[index] = InstructionState {
text: instruction,
status: InstructionStatus::Running,
};
self.state.current_instruction = index;
}
}
BuildEvent::Output { line, is_stderr } => {
self.state.output_lines.push(OutputLine {
text: line,
is_stderr,
});
let visible_lines = 10; let max_scroll = self.state.output_lines.len().saturating_sub(visible_lines);
if self.state.scroll_offset >= max_scroll.saturating_sub(1) {
self.state.scroll_offset =
self.state.output_lines.len().saturating_sub(visible_lines);
}
}
BuildEvent::InstructionComplete {
stage,
index,
cached,
} => {
if let Some(stage_state) = self.state.stages.get_mut(stage) {
if let Some(inst) = stage_state.instructions.get_mut(index) {
inst.status = InstructionStatus::Complete { cached };
}
}
}
BuildEvent::StageComplete { index } => {
if let Some(stage_state) = self.state.stages.get_mut(index) {
stage_state.complete = true;
}
}
BuildEvent::BuildComplete { image_id } => {
self.state.completed = true;
self.state.image_id = Some(image_id);
}
BuildEvent::BuildFailed { error } => {
self.state.completed = true;
self.state.error = Some(error);
if let Some(stage_state) = self.state.stages.get_mut(self.state.current_stage) {
if let Some(inst) = stage_state
.instructions
.get_mut(self.state.current_instruction)
{
if inst.status.is_running() {
inst.status = InstructionStatus::Failed;
}
}
}
}
}
}
fn handle_input(&mut self, key: KeyCode, modifiers: KeyModifiers) {
match key {
KeyCode::Char('q') | KeyCode::Esc => {
self.running = false;
}
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
self.running = false;
}
KeyCode::Up | KeyCode::Char('k') => {
self.state.scroll_offset = self.state.scroll_offset.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
let max_scroll = self.state.output_lines.len().saturating_sub(10);
if self.state.scroll_offset < max_scroll {
self.state.scroll_offset += 1;
}
}
KeyCode::PageUp => {
self.state.scroll_offset = self.state.scroll_offset.saturating_sub(10);
}
KeyCode::PageDown => {
let max_scroll = self.state.output_lines.len().saturating_sub(10);
self.state.scroll_offset = (self.state.scroll_offset + 10).min(max_scroll);
}
KeyCode::Home => {
self.state.scroll_offset = 0;
}
KeyCode::End => {
let max_scroll = self.state.output_lines.len().saturating_sub(10);
self.state.scroll_offset = max_scroll;
}
_ => {}
}
}
fn render(&self, frame: &mut Frame) {
let view = BuildView::new(&self.state);
frame.render_widget(view, frame.area());
}
}
impl BuildState {
#[must_use]
pub fn total_instructions(&self) -> usize {
self.stages.iter().map(|s| s.instructions.len()).sum()
}
#[must_use]
pub fn completed_instructions(&self) -> usize {
self.stages
.iter()
.flat_map(|s| s.instructions.iter())
.filter(|i| i.status.is_complete())
.count()
}
#[must_use]
pub fn current_stage_display(&self) -> String {
if let Some(stage) = self.stages.get(self.current_stage) {
let name_part = stage
.name
.as_ref()
.map(|n| format!("{n} "))
.unwrap_or_default();
format!(
"Stage {}/{}: {}({})",
self.current_stage + 1,
self.stages.len().max(1),
name_part,
stage.base_image
)
} else {
"Initializing...".to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
#[test]
fn test_build_state_default() {
let state = BuildState::default();
assert!(state.stages.is_empty());
assert!(!state.completed);
assert!(state.error.is_none());
assert!(state.image_id.is_none());
}
#[test]
fn test_build_state_instruction_counts() {
let mut state = BuildState::default();
state.stages.push(StageState {
index: 0,
name: None,
base_image: "alpine".to_string(),
instructions: vec![
InstructionState {
text: "RUN echo 1".to_string(),
status: InstructionStatus::Complete { cached: false },
},
InstructionState {
text: "RUN echo 2".to_string(),
status: InstructionStatus::Running,
},
],
complete: false,
});
assert_eq!(state.total_instructions(), 2);
assert_eq!(state.completed_instructions(), 1);
}
#[test]
fn test_handle_stage_started() {
let (tx, rx) = mpsc::channel();
let mut tui = BuildTui::new(rx);
tx.send(BuildEvent::StageStarted {
index: 0,
name: Some("builder".to_string()),
base_image: "node:20".to_string(),
})
.unwrap();
drop(tx);
tui.process_events();
assert_eq!(tui.state.stages.len(), 1);
assert_eq!(tui.state.stages[0].name, Some("builder".to_string()));
assert_eq!(tui.state.stages[0].base_image, "node:20");
}
#[test]
fn test_handle_instruction_lifecycle() {
let (tx, rx) = mpsc::channel();
let mut tui = BuildTui::new(rx);
tx.send(BuildEvent::StageStarted {
index: 0,
name: None,
base_image: "alpine".to_string(),
})
.unwrap();
tx.send(BuildEvent::InstructionStarted {
stage: 0,
index: 0,
instruction: "RUN echo hello".to_string(),
})
.unwrap();
tx.send(BuildEvent::InstructionComplete {
stage: 0,
index: 0,
cached: true,
})
.unwrap();
drop(tx);
tui.process_events();
let inst = &tui.state.stages[0].instructions[0];
assert_eq!(inst.text, "RUN echo hello");
assert!(matches!(
inst.status,
InstructionStatus::Complete { cached: true }
));
}
#[test]
fn test_handle_build_complete() {
let (tx, rx) = mpsc::channel();
let mut tui = BuildTui::new(rx);
tx.send(BuildEvent::BuildComplete {
image_id: "sha256:abc123".to_string(),
})
.unwrap();
drop(tx);
tui.process_events();
assert!(tui.state.completed);
assert_eq!(tui.state.image_id, Some("sha256:abc123".to_string()));
assert!(tui.state.error.is_none());
}
#[test]
fn test_handle_build_failed() {
let (tx, rx) = mpsc::channel();
let mut tui = BuildTui::new(rx);
tx.send(BuildEvent::StageStarted {
index: 0,
name: None,
base_image: "alpine".to_string(),
})
.unwrap();
tx.send(BuildEvent::InstructionStarted {
stage: 0,
index: 0,
instruction: "RUN exit 1".to_string(),
})
.unwrap();
tx.send(BuildEvent::BuildFailed {
error: "Command failed with exit code 1".to_string(),
})
.unwrap();
drop(tx);
tui.process_events();
assert!(tui.state.completed);
assert!(tui.state.error.is_some());
assert!(tui.state.stages[0].instructions[0].status.is_failed());
}
}