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    pub fn new(state: &'a BuildState) -> Self {
24        Self { state }
25    }
26
27    /// Calculate the layout for the view
28    fn layout(&self, area: Rect) -> (Rect, Rect, Rect, Rect) {
29        // Main layout:
30        // - Header: Stage info + progress bar (3 lines)
31        // - Instructions: List of instructions (flexible)
32        // - Output: Streaming output (flexible)
33        // - Footer: Help text (1 line)
34
35        let chunks = Layout::default()
36            .direction(Direction::Vertical)
37            .constraints([
38                Constraint::Length(4), // Header with progress
39                Constraint::Min(6),    // Instructions list
40                Constraint::Min(8),    // Output log
41                Constraint::Length(1), // Footer
42            ])
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        // Render header with stage info and progress
54        self.render_header(header_area, buf);
55
56        // Render instructions list
57        self.render_instructions(instructions_area, buf);
58
59        // Render output log
60        self.render_output(output_area, buf);
61
62        // Render footer
63        self.render_footer(footer_area, buf);
64    }
65}
66
67impl BuildView<'_> {
68    /// Render the header section with stage info and progress bar
69    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        // Split inner area for stage info and progress bar
79        let chunks = Layout::default()
80            .direction(Direction::Vertical)
81            .constraints([Constraint::Length(1), Constraint::Length(1)])
82            .split(inner);
83
84        // Stage info
85        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        // Progress bar
99        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    /// Get the border style for the header based on build status
108    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    /// Render the instructions list
119    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    /// Render the output log
152    fn render_output(&self, area: Rect, buf: &mut Buffer) {
153        // Show completion message or error if build is done
154        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    /// Render the footer with help text
190    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        // Check that all areas are within bounds
272        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        // Check that areas don't overlap
278        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}