Skip to main content

zlayer_builder/tui/
app.rs

1//! Main TUI application
2//!
3//! This module contains the main `BuildTui` application that manages
4//! the terminal UI, event processing, and rendering loop.
5
6use std::io::{self, Stdout};
7use std::sync::mpsc::{Receiver, TryRecvError};
8
9use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
10use ratatui::prelude::*;
11use ratatui::Terminal;
12use zlayer_tui::terminal::{restore_terminal, setup_terminal, POLL_DURATION};
13use zlayer_tui::widgets::scrollable_pane::OutputLine;
14
15use super::build_view::BuildView;
16use super::{BuildEvent, InstructionStatus};
17
18/// Main TUI application for build progress visualization
19pub struct BuildTui {
20    /// Channel to receive build events
21    event_rx: Receiver<BuildEvent>,
22    /// Current build state
23    state: BuildState,
24    /// Whether to keep running
25    running: bool,
26}
27
28/// Internal build state tracking
29#[derive(Debug, Default)]
30pub struct BuildState {
31    /// All stages in the build
32    pub stages: Vec<StageState>,
33    /// Current stage index
34    pub current_stage: usize,
35    /// Current instruction index within the current stage
36    pub current_instruction: usize,
37    /// Output lines from the build process
38    pub output_lines: Vec<OutputLine>,
39    /// Current scroll offset for output
40    pub scroll_offset: usize,
41    /// Whether the build has completed
42    pub completed: bool,
43    /// Error message if build failed
44    pub error: Option<String>,
45    /// Final image ID if build succeeded
46    pub image_id: Option<String>,
47}
48
49/// State of a single build stage
50#[derive(Debug, Clone)]
51pub struct StageState {
52    /// Stage index
53    pub index: usize,
54    /// Optional stage name
55    pub name: Option<String>,
56    /// Base image for this stage
57    pub base_image: String,
58    /// Instructions in this stage
59    pub instructions: Vec<InstructionState>,
60    /// Whether the stage is complete
61    pub complete: bool,
62}
63
64/// State of a single instruction
65#[derive(Debug, Clone)]
66pub struct InstructionState {
67    /// Instruction text
68    pub text: String,
69    /// Current status
70    pub status: InstructionStatus,
71}
72
73impl BuildTui {
74    /// Create a new TUI with an event receiver
75    #[must_use]
76    pub fn new(event_rx: Receiver<BuildEvent>) -> Self {
77        Self {
78            event_rx,
79            state: BuildState::default(),
80            running: true,
81        }
82    }
83
84    /// Run the TUI (blocking)
85    ///
86    /// This will take over the terminal, display the build progress,
87    /// and return when the build completes or the user quits (q key).
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if terminal setup, rendering, or restoration fails.
92    pub fn run(&mut self) -> io::Result<()> {
93        let mut terminal = setup_terminal()?;
94        let result = self.run_loop(&mut terminal);
95        restore_terminal(&mut terminal)?;
96        result
97    }
98
99    /// Main event loop
100    fn run_loop(&mut self, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
101        while self.running {
102            // Process any pending build events
103            self.process_events();
104
105            // Render the UI
106            terminal.draw(|frame| self.render(frame))?;
107
108            // Auto-exit when build completes (success or failure).
109            // The TUI disappears and the caller prints a summary to stdout.
110            if self.state.completed {
111                break;
112            }
113
114            // Handle keyboard input with a short timeout
115            if event::poll(POLL_DURATION)? {
116                if let Event::Key(key) = event::read()? {
117                    if key.kind == KeyEventKind::Press {
118                        self.handle_input(key.code, key.modifiers);
119                    }
120                }
121            }
122        }
123
124        Ok(())
125    }
126
127    /// Process pending build events from the channel
128    fn process_events(&mut self) {
129        loop {
130            match self.event_rx.try_recv() {
131                Ok(event) => self.handle_build_event(event),
132                Err(TryRecvError::Empty) => break,
133                Err(TryRecvError::Disconnected) => {
134                    // Build process has finished sending events
135                    if !self.state.completed {
136                        // If we didn't get a completion event, treat as unexpected end
137                        self.state.completed = true;
138                        if self.state.error.is_none() && self.state.image_id.is_none() {
139                            self.state.error = Some("Build ended unexpectedly".to_string());
140                        }
141                    }
142                    break;
143                }
144            }
145        }
146    }
147
148    /// Handle a single build event
149    fn handle_build_event(&mut self, event: BuildEvent) {
150        match event {
151            BuildEvent::StageStarted {
152                index,
153                name,
154                base_image,
155            } => {
156                // Ensure we have enough stages
157                while self.state.stages.len() <= index {
158                    self.state.stages.push(StageState {
159                        index: self.state.stages.len(),
160                        name: None,
161                        base_image: String::new(),
162                        instructions: Vec::new(),
163                        complete: false,
164                    });
165                }
166
167                // Update the stage
168                self.state.stages[index] = StageState {
169                    index,
170                    name,
171                    base_image,
172                    instructions: Vec::new(),
173                    complete: false,
174                };
175                self.state.current_stage = index;
176                self.state.current_instruction = 0;
177            }
178
179            BuildEvent::InstructionStarted {
180                stage,
181                index,
182                instruction,
183            } => {
184                if let Some(stage_state) = self.state.stages.get_mut(stage) {
185                    // Ensure we have enough instructions
186                    while stage_state.instructions.len() <= index {
187                        stage_state.instructions.push(InstructionState {
188                            text: String::new(),
189                            status: InstructionStatus::Pending,
190                        });
191                    }
192
193                    // Update the instruction
194                    stage_state.instructions[index] = InstructionState {
195                        text: instruction,
196                        status: InstructionStatus::Running,
197                    };
198                    self.state.current_instruction = index;
199                }
200            }
201
202            BuildEvent::Output { line, is_stderr } => {
203                self.state.output_lines.push(OutputLine {
204                    text: line,
205                    is_stderr,
206                });
207
208                // Auto-scroll to bottom if we were already at the bottom
209                let visible_lines = 10; // Approximate
210                let max_scroll = self.state.output_lines.len().saturating_sub(visible_lines);
211                if self.state.scroll_offset >= max_scroll.saturating_sub(1) {
212                    self.state.scroll_offset =
213                        self.state.output_lines.len().saturating_sub(visible_lines);
214                }
215            }
216
217            BuildEvent::InstructionComplete {
218                stage,
219                index,
220                cached,
221            } => {
222                if let Some(stage_state) = self.state.stages.get_mut(stage) {
223                    if let Some(inst) = stage_state.instructions.get_mut(index) {
224                        inst.status = InstructionStatus::Complete { cached };
225                    }
226                }
227            }
228
229            BuildEvent::StageComplete { index } => {
230                if let Some(stage_state) = self.state.stages.get_mut(index) {
231                    stage_state.complete = true;
232                }
233            }
234
235            BuildEvent::BuildComplete { image_id } => {
236                self.state.completed = true;
237                self.state.image_id = Some(image_id);
238            }
239
240            BuildEvent::BuildFailed { error } => {
241                self.state.completed = true;
242                self.state.error = Some(error);
243
244                // Mark current instruction as failed
245                if let Some(stage_state) = self.state.stages.get_mut(self.state.current_stage) {
246                    if let Some(inst) = stage_state
247                        .instructions
248                        .get_mut(self.state.current_instruction)
249                    {
250                        if inst.status.is_running() {
251                            inst.status = InstructionStatus::Failed;
252                        }
253                    }
254                }
255            }
256        }
257    }
258
259    /// Handle keyboard input
260    fn handle_input(&mut self, key: KeyCode, modifiers: KeyModifiers) {
261        match key {
262            KeyCode::Char('q') | KeyCode::Esc => {
263                self.running = false;
264            }
265            KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
266                self.running = false;
267            }
268            KeyCode::Up | KeyCode::Char('k') => {
269                self.state.scroll_offset = self.state.scroll_offset.saturating_sub(1);
270            }
271            KeyCode::Down | KeyCode::Char('j') => {
272                let max_scroll = self.state.output_lines.len().saturating_sub(10);
273                if self.state.scroll_offset < max_scroll {
274                    self.state.scroll_offset += 1;
275                }
276            }
277            KeyCode::PageUp => {
278                self.state.scroll_offset = self.state.scroll_offset.saturating_sub(10);
279            }
280            KeyCode::PageDown => {
281                let max_scroll = self.state.output_lines.len().saturating_sub(10);
282                self.state.scroll_offset = (self.state.scroll_offset + 10).min(max_scroll);
283            }
284            KeyCode::Home => {
285                self.state.scroll_offset = 0;
286            }
287            KeyCode::End => {
288                let max_scroll = self.state.output_lines.len().saturating_sub(10);
289                self.state.scroll_offset = max_scroll;
290            }
291            _ => {}
292        }
293    }
294
295    /// Render the UI
296    fn render(&self, frame: &mut Frame) {
297        let view = BuildView::new(&self.state);
298        frame.render_widget(view, frame.area());
299    }
300}
301
302impl BuildState {
303    /// Get the total number of instructions across all stages
304    #[must_use]
305    pub fn total_instructions(&self) -> usize {
306        self.stages.iter().map(|s| s.instructions.len()).sum()
307    }
308
309    /// Get the number of completed instructions across all stages
310    #[must_use]
311    pub fn completed_instructions(&self) -> usize {
312        self.stages
313            .iter()
314            .flat_map(|s| s.instructions.iter())
315            .filter(|i| i.status.is_complete())
316            .count()
317    }
318
319    /// Get a display string for the current stage
320    #[must_use]
321    pub fn current_stage_display(&self) -> String {
322        if let Some(stage) = self.stages.get(self.current_stage) {
323            let name_part = stage
324                .name
325                .as_ref()
326                .map(|n| format!("{n} "))
327                .unwrap_or_default();
328            format!(
329                "Stage {}/{}: {}({})",
330                self.current_stage + 1,
331                self.stages.len().max(1),
332                name_part,
333                stage.base_image
334            )
335        } else {
336            "Initializing...".to_string()
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use std::sync::mpsc;
345
346    #[test]
347    fn test_build_state_default() {
348        let state = BuildState::default();
349        assert!(state.stages.is_empty());
350        assert!(!state.completed);
351        assert!(state.error.is_none());
352        assert!(state.image_id.is_none());
353    }
354
355    #[test]
356    fn test_build_state_instruction_counts() {
357        let mut state = BuildState::default();
358        state.stages.push(StageState {
359            index: 0,
360            name: None,
361            base_image: "alpine".to_string(),
362            instructions: vec![
363                InstructionState {
364                    text: "RUN echo 1".to_string(),
365                    status: InstructionStatus::Complete { cached: false },
366                },
367                InstructionState {
368                    text: "RUN echo 2".to_string(),
369                    status: InstructionStatus::Running,
370                },
371            ],
372            complete: false,
373        });
374
375        assert_eq!(state.total_instructions(), 2);
376        assert_eq!(state.completed_instructions(), 1);
377    }
378
379    #[test]
380    fn test_handle_stage_started() {
381        let (tx, rx) = mpsc::channel();
382        let mut tui = BuildTui::new(rx);
383
384        tx.send(BuildEvent::StageStarted {
385            index: 0,
386            name: Some("builder".to_string()),
387            base_image: "node:20".to_string(),
388        })
389        .unwrap();
390
391        drop(tx);
392        tui.process_events();
393
394        assert_eq!(tui.state.stages.len(), 1);
395        assert_eq!(tui.state.stages[0].name, Some("builder".to_string()));
396        assert_eq!(tui.state.stages[0].base_image, "node:20");
397    }
398
399    #[test]
400    fn test_handle_instruction_lifecycle() {
401        let (tx, rx) = mpsc::channel();
402        let mut tui = BuildTui::new(rx);
403
404        // Start stage
405        tx.send(BuildEvent::StageStarted {
406            index: 0,
407            name: None,
408            base_image: "alpine".to_string(),
409        })
410        .unwrap();
411
412        // Start instruction
413        tx.send(BuildEvent::InstructionStarted {
414            stage: 0,
415            index: 0,
416            instruction: "RUN echo hello".to_string(),
417        })
418        .unwrap();
419
420        // Complete instruction
421        tx.send(BuildEvent::InstructionComplete {
422            stage: 0,
423            index: 0,
424            cached: true,
425        })
426        .unwrap();
427
428        drop(tx);
429        tui.process_events();
430
431        let inst = &tui.state.stages[0].instructions[0];
432        assert_eq!(inst.text, "RUN echo hello");
433        assert!(matches!(
434            inst.status,
435            InstructionStatus::Complete { cached: true }
436        ));
437    }
438
439    #[test]
440    fn test_handle_build_complete() {
441        let (tx, rx) = mpsc::channel();
442        let mut tui = BuildTui::new(rx);
443
444        tx.send(BuildEvent::BuildComplete {
445            image_id: "sha256:abc123".to_string(),
446        })
447        .unwrap();
448
449        drop(tx);
450        tui.process_events();
451
452        assert!(tui.state.completed);
453        assert_eq!(tui.state.image_id, Some("sha256:abc123".to_string()));
454        assert!(tui.state.error.is_none());
455    }
456
457    #[test]
458    fn test_handle_build_failed() {
459        let (tx, rx) = mpsc::channel();
460        let mut tui = BuildTui::new(rx);
461
462        // Set up a running instruction first
463        tx.send(BuildEvent::StageStarted {
464            index: 0,
465            name: None,
466            base_image: "alpine".to_string(),
467        })
468        .unwrap();
469
470        tx.send(BuildEvent::InstructionStarted {
471            stage: 0,
472            index: 0,
473            instruction: "RUN exit 1".to_string(),
474        })
475        .unwrap();
476
477        tx.send(BuildEvent::BuildFailed {
478            error: "Command failed with exit code 1".to_string(),
479        })
480        .unwrap();
481
482        drop(tx);
483        tui.process_events();
484
485        assert!(tui.state.completed);
486        assert!(tui.state.error.is_some());
487        assert!(tui.state.stages[0].instructions[0].status.is_failed());
488    }
489}