use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use zlayer_tui::palette::color;
use zlayer_tui::widgets::progress_bar::ProgressBar;
use zlayer_tui::widgets::scrollable_pane::ScrollablePane;
use super::app::BuildState;
use super::widgets::InstructionList;
pub struct BuildView<'a> {
state: &'a BuildState,
}
impl<'a> BuildView<'a> {
#[must_use]
pub fn new(state: &'a BuildState) -> Self {
Self { state }
}
#[allow(clippy::unused_self)]
fn layout(&self, area: Rect) -> (Rect, Rect, Rect, Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4), Constraint::Min(6), Constraint::Min(8), Constraint::Length(1), ])
.split(area);
(chunks[0], chunks[1], chunks[2], chunks[3])
}
}
impl Widget for BuildView<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let (header_area, instructions_area, output_area, footer_area) = self.layout(area);
self.render_header(header_area, buf);
self.render_instructions(instructions_area, buf);
self.render_output(output_area, buf);
self.render_footer(footer_area, buf);
}
}
impl BuildView<'_> {
fn render_header(&self, area: Rect, buf: &mut Buffer) {
let block = Block::default()
.title(" Build Progress ")
.borders(Borders::ALL)
.border_style(self.header_border_style());
let inner = block.inner(area);
block.render(area, buf);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(inner);
let stage_info = self.state.current_stage_display();
let stage_style = if self.state.error.is_some() {
Style::default().fg(color::ERROR)
} else if self.state.completed {
Style::default().fg(color::SUCCESS)
} else {
Style::default().fg(color::ACCENT)
};
Paragraph::new(stage_info)
.style(stage_style)
.render(chunks[0], buf);
let total = self.state.total_instructions().max(1);
let completed = self.state.completed_instructions();
let progress = ProgressBar::new(completed, total)
.with_label(format!("{completed}/{total} instructions"));
progress.render(chunks[1], buf);
}
fn header_border_style(&self) -> Style {
if self.state.error.is_some() {
Style::default().fg(color::ERROR)
} else if self.state.completed {
Style::default().fg(color::SUCCESS)
} else {
Style::default().fg(color::ACTIVE_BORDER)
}
}
fn render_instructions(&self, area: Rect, buf: &mut Buffer) {
let title = if let Some(stage) = self.state.stages.get(self.state.current_stage) {
if let Some(ref name) = stage.name {
format!(" Stage: {name} ")
} else {
format!(" Stage {} ", self.state.current_stage + 1)
}
} else {
" Instructions ".to_string()
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(color::INACTIVE));
let inner = block.inner(area);
block.render(area, buf);
if let Some(stage) = self.state.stages.get(self.state.current_stage) {
let list = InstructionList {
instructions: &stage.instructions,
current: self.state.current_instruction,
};
list.render(inner, buf);
} else {
Paragraph::new("Waiting for build to start...")
.style(Style::default().fg(color::INACTIVE))
.render(inner, buf);
}
}
fn render_output(&self, area: Rect, buf: &mut Buffer) {
if let Some(ref error) = self.state.error {
let block = Block::default()
.title(" Output ")
.borders(Borders::ALL)
.border_style(Style::default().fg(color::INACTIVE));
let inner = block.inner(area);
block.render(area, buf);
let error_text = format!("Build failed: {error}");
Paragraph::new(error_text)
.style(Style::default().fg(color::ERROR))
.wrap(Wrap { trim: false })
.render(inner, buf);
} else if let Some(ref image_id) = self.state.image_id {
let block = Block::default()
.title(" Output ")
.borders(Borders::ALL)
.border_style(Style::default().fg(color::INACTIVE));
let inner = block.inner(area);
block.render(area, buf);
let success_text = format!("Build complete!\n\nImage: {image_id}");
Paragraph::new(success_text)
.style(Style::default().fg(color::SUCCESS))
.render(inner, buf);
} else {
let pane = ScrollablePane::new(&self.state.output_lines, self.state.scroll_offset)
.with_title("Output")
.with_empty_text("Waiting for output...");
pane.render(area, buf);
}
}
fn render_footer(&self, area: Rect, buf: &mut Buffer) {
let help_text = if self.state.completed {
"Press 'q' to exit"
} else {
"q: quit | arrows/jk: scroll | PgUp/PgDn: page"
};
Paragraph::new(help_text)
.style(Style::default().fg(color::INACTIVE))
.alignment(Alignment::Center)
.render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::app::{InstructionState, StageState};
use crate::tui::InstructionStatus;
use zlayer_tui::widgets::scrollable_pane::OutputLine;
fn create_test_state() -> BuildState {
BuildState {
stages: vec![StageState {
index: 0,
name: Some("builder".to_string()),
base_image: "node:20-alpine".to_string(),
instructions: vec![
InstructionState {
text: "WORKDIR /app".to_string(),
status: InstructionStatus::Complete { cached: false },
},
InstructionState {
text: "COPY package*.json ./".to_string(),
status: InstructionStatus::Complete { cached: true },
},
InstructionState {
text: "RUN npm ci".to_string(),
status: InstructionStatus::Running,
},
InstructionState {
text: "COPY . .".to_string(),
status: InstructionStatus::Pending,
},
],
complete: false,
}],
current_stage: 0,
current_instruction: 2,
output_lines: vec![
OutputLine {
text: "npm warn deprecated inflight@1.0.6".to_string(),
is_stderr: true,
},
OutputLine {
text: "added 847 packages in 12s".to_string(),
is_stderr: false,
},
],
scroll_offset: 0,
completed: false,
error: None,
image_id: None,
}
}
#[test]
fn test_build_view_creation() {
let state = create_test_state();
let view = BuildView::new(&state);
assert_eq!(view.state.current_stage, 0);
}
#[test]
fn test_layout_calculation() {
let state = create_test_state();
let view = BuildView::new(&state);
let area = Rect::new(0, 0, 80, 24);
let (header, instructions, output, footer) = view.layout(area);
assert!(header.y + header.height <= area.height);
assert!(instructions.y + instructions.height <= area.height);
assert!(output.y + output.height <= area.height);
assert!(footer.y + footer.height <= area.height);
assert!(header.y + header.height <= instructions.y);
assert!(instructions.y + instructions.height <= output.y);
assert!(output.y + output.height <= footer.y);
}
#[test]
fn test_header_border_style_normal() {
let state = BuildState::default();
let view = BuildView::new(&state);
let style = view.header_border_style();
assert_eq!(style.fg, Some(color::ACTIVE_BORDER));
}
#[test]
fn test_header_border_style_error() {
let state = BuildState {
error: Some("test error".to_string()),
..Default::default()
};
let view = BuildView::new(&state);
let style = view.header_border_style();
assert_eq!(style.fg, Some(color::ERROR));
}
#[test]
fn test_header_border_style_complete() {
let state = BuildState {
completed: true,
image_id: Some("sha256:abc".to_string()),
..Default::default()
};
let view = BuildView::new(&state);
let style = view.header_border_style();
assert_eq!(style.fg, Some(color::SUCCESS));
}
}