1use ratatui::prelude::*;
7use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
8
9use zlayer_tui::palette::color;
10use zlayer_tui::widgets::progress_bar::ProgressBar;
11use zlayer_tui::widgets::scrollable_pane::ScrollablePane;
12
13use super::app::BuildState;
14use super::widgets::InstructionList;
15
16pub struct BuildView<'a> {
18 state: &'a BuildState,
19}
20
21impl<'a> BuildView<'a> {
22 #[must_use]
24 pub fn new(state: &'a BuildState) -> Self {
25 Self { state }
26 }
27
28 #[allow(clippy::unused_self)]
30 fn layout(&self, area: Rect) -> (Rect, Rect, Rect, Rect) {
31 let chunks = Layout::default()
38 .direction(Direction::Vertical)
39 .constraints([
40 Constraint::Length(4), Constraint::Min(6), Constraint::Min(8), Constraint::Length(1), ])
45 .split(area);
46
47 (chunks[0], chunks[1], chunks[2], chunks[3])
48 }
49}
50
51impl Widget for BuildView<'_> {
52 fn render(self, area: Rect, buf: &mut Buffer) {
53 let (header_area, instructions_area, output_area, footer_area) = self.layout(area);
54
55 self.render_header(header_area, buf);
57
58 self.render_instructions(instructions_area, buf);
60
61 self.render_output(output_area, buf);
63
64 self.render_footer(footer_area, buf);
66 }
67}
68
69impl BuildView<'_> {
70 fn render_header(&self, area: Rect, buf: &mut Buffer) {
72 let block = Block::default()
73 .title(" Build Progress ")
74 .borders(Borders::ALL)
75 .border_style(self.header_border_style());
76
77 let inner = block.inner(area);
78 block.render(area, buf);
79
80 let chunks = Layout::default()
82 .direction(Direction::Vertical)
83 .constraints([Constraint::Length(1), Constraint::Length(1)])
84 .split(inner);
85
86 let stage_info = self.state.current_stage_display();
88 let stage_style = if self.state.error.is_some() {
89 Style::default().fg(color::ERROR)
90 } else if self.state.completed {
91 Style::default().fg(color::SUCCESS)
92 } else {
93 Style::default().fg(color::ACCENT)
94 };
95
96 Paragraph::new(stage_info)
97 .style(stage_style)
98 .render(chunks[0], buf);
99
100 let total = self.state.total_instructions().max(1);
102 let completed = self.state.completed_instructions();
103
104 let progress = ProgressBar::new(completed, total)
105 .with_label(format!("{completed}/{total} instructions"));
106 progress.render(chunks[1], buf);
107 }
108
109 fn header_border_style(&self) -> Style {
111 if self.state.error.is_some() {
112 Style::default().fg(color::ERROR)
113 } else if self.state.completed {
114 Style::default().fg(color::SUCCESS)
115 } else {
116 Style::default().fg(color::ACTIVE_BORDER)
117 }
118 }
119
120 fn render_instructions(&self, area: Rect, buf: &mut Buffer) {
122 let title = if let Some(stage) = self.state.stages.get(self.state.current_stage) {
123 if let Some(ref name) = stage.name {
124 format!(" Stage: {name} ")
125 } else {
126 format!(" Stage {} ", self.state.current_stage + 1)
127 }
128 } else {
129 " Instructions ".to_string()
130 };
131
132 let block = Block::default()
133 .title(title)
134 .borders(Borders::ALL)
135 .border_style(Style::default().fg(color::INACTIVE));
136
137 let inner = block.inner(area);
138 block.render(area, buf);
139
140 if let Some(stage) = self.state.stages.get(self.state.current_stage) {
141 let list = InstructionList {
142 instructions: &stage.instructions,
143 current: self.state.current_instruction,
144 };
145 list.render(inner, buf);
146 } else {
147 Paragraph::new("Waiting for build to start...")
148 .style(Style::default().fg(color::INACTIVE))
149 .render(inner, buf);
150 }
151 }
152
153 fn render_output(&self, area: Rect, buf: &mut Buffer) {
155 if let Some(ref error) = self.state.error {
157 let block = Block::default()
158 .title(" Output ")
159 .borders(Borders::ALL)
160 .border_style(Style::default().fg(color::INACTIVE));
161
162 let inner = block.inner(area);
163 block.render(area, buf);
164
165 let error_text = format!("Build failed: {error}");
166 Paragraph::new(error_text)
167 .style(Style::default().fg(color::ERROR))
168 .wrap(Wrap { trim: false })
169 .render(inner, buf);
170 } else if let Some(ref image_id) = self.state.image_id {
171 let block = Block::default()
172 .title(" Output ")
173 .borders(Borders::ALL)
174 .border_style(Style::default().fg(color::INACTIVE));
175
176 let inner = block.inner(area);
177 block.render(area, buf);
178
179 let success_text = format!("Build complete!\n\nImage: {image_id}");
180 Paragraph::new(success_text)
181 .style(Style::default().fg(color::SUCCESS))
182 .render(inner, buf);
183 } else {
184 let pane = ScrollablePane::new(&self.state.output_lines, self.state.scroll_offset)
185 .with_title("Output")
186 .with_empty_text("Waiting for output...");
187 pane.render(area, buf);
188 }
189 }
190
191 fn render_footer(&self, area: Rect, buf: &mut Buffer) {
193 let help_text = if self.state.completed {
194 "Press 'q' to exit"
195 } else {
196 "q: quit | arrows/jk: scroll | PgUp/PgDn: page"
197 };
198
199 Paragraph::new(help_text)
200 .style(Style::default().fg(color::INACTIVE))
201 .alignment(Alignment::Center)
202 .render(area, buf);
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use crate::tui::app::{InstructionState, StageState};
210 use crate::tui::InstructionStatus;
211 use zlayer_tui::widgets::scrollable_pane::OutputLine;
212
213 fn create_test_state() -> BuildState {
214 BuildState {
215 stages: vec![StageState {
216 index: 0,
217 name: Some("builder".to_string()),
218 base_image: "node:20-alpine".to_string(),
219 instructions: vec![
220 InstructionState {
221 text: "WORKDIR /app".to_string(),
222 status: InstructionStatus::Complete { cached: false },
223 },
224 InstructionState {
225 text: "COPY package*.json ./".to_string(),
226 status: InstructionStatus::Complete { cached: true },
227 },
228 InstructionState {
229 text: "RUN npm ci".to_string(),
230 status: InstructionStatus::Running,
231 },
232 InstructionState {
233 text: "COPY . .".to_string(),
234 status: InstructionStatus::Pending,
235 },
236 ],
237 complete: false,
238 }],
239 current_stage: 0,
240 current_instruction: 2,
241 output_lines: vec![
242 OutputLine {
243 text: "npm warn deprecated inflight@1.0.6".to_string(),
244 is_stderr: true,
245 },
246 OutputLine {
247 text: "added 847 packages in 12s".to_string(),
248 is_stderr: false,
249 },
250 ],
251 scroll_offset: 0,
252 completed: false,
253 error: None,
254 image_id: None,
255 }
256 }
257
258 #[test]
259 fn test_build_view_creation() {
260 let state = create_test_state();
261 let view = BuildView::new(&state);
262 assert_eq!(view.state.current_stage, 0);
263 }
264
265 #[test]
266 fn test_layout_calculation() {
267 let state = create_test_state();
268 let view = BuildView::new(&state);
269 let area = Rect::new(0, 0, 80, 24);
270
271 let (header, instructions, output, footer) = view.layout(area);
272
273 assert!(header.y + header.height <= area.height);
275 assert!(instructions.y + instructions.height <= area.height);
276 assert!(output.y + output.height <= area.height);
277 assert!(footer.y + footer.height <= area.height);
278
279 assert!(header.y + header.height <= instructions.y);
281 assert!(instructions.y + instructions.height <= output.y);
282 assert!(output.y + output.height <= footer.y);
283 }
284
285 #[test]
286 fn test_header_border_style_normal() {
287 let state = BuildState::default();
288 let view = BuildView::new(&state);
289 let style = view.header_border_style();
290 assert_eq!(style.fg, Some(color::ACTIVE_BORDER));
291 }
292
293 #[test]
294 fn test_header_border_style_error() {
295 let state = BuildState {
296 error: Some("test error".to_string()),
297 ..Default::default()
298 };
299 let view = BuildView::new(&state);
300 let style = view.header_border_style();
301 assert_eq!(style.fg, Some(color::ERROR));
302 }
303
304 #[test]
305 fn test_header_border_style_complete() {
306 let state = BuildState {
307 completed: true,
308 image_id: Some("sha256:abc".to_string()),
309 ..Default::default()
310 };
311 let view = BuildView::new(&state);
312 let style = view.header_border_style();
313 assert_eq!(style.fg, Some(color::SUCCESS));
314 }
315}