Skip to main content

zlayer_builder/tui/
build_view.rs

1//! Build progress view layout
2//!
3//! This module contains the main build view widget that composes
4//! the progress display from smaller widgets.
5
6use 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
16/// Main build progress view widget
17pub struct BuildView<'a> {
18    state: &'a BuildState,
19}
20
21impl<'a> BuildView<'a> {
22    /// Create a new build view
23    #[must_use]
24    pub fn new(state: &'a BuildState) -> Self {
25        Self { state }
26    }
27
28    /// Calculate the layout for the view
29    #[allow(clippy::unused_self)]
30    fn layout(&self, area: Rect) -> (Rect, Rect, Rect, Rect) {
31        // Main layout:
32        // - Header: Stage info + progress bar (3 lines)
33        // - Instructions: List of instructions (flexible)
34        // - Output: Streaming output (flexible)
35        // - Footer: Help text (1 line)
36
37        let chunks = Layout::default()
38            .direction(Direction::Vertical)
39            .constraints([
40                Constraint::Length(4), // Header with progress
41                Constraint::Min(6),    // Instructions list
42                Constraint::Min(8),    // Output log
43                Constraint::Length(1), // Footer
44            ])
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        // Render header with stage info and progress
56        self.render_header(header_area, buf);
57
58        // Render instructions list
59        self.render_instructions(instructions_area, buf);
60
61        // Render output log
62        self.render_output(output_area, buf);
63
64        // Render footer
65        self.render_footer(footer_area, buf);
66    }
67}
68
69impl BuildView<'_> {
70    /// Render the header section with stage info and progress bar
71    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        // Split inner area for stage info and progress bar
81        let chunks = Layout::default()
82            .direction(Direction::Vertical)
83            .constraints([Constraint::Length(1), Constraint::Length(1)])
84            .split(inner);
85
86        // Stage info
87        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        // Progress bar
101        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    /// Get the border style for the header based on build status
110    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    /// Render the instructions list
121    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    /// Render the output log
154    fn render_output(&self, area: Rect, buf: &mut Buffer) {
155        // Show completion message or error if build is done
156        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    /// Render the footer with help text
192    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        // Check that all areas are within bounds
274        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        // Check that areas don't overlap
280        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}