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