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    /// Total stages pre-declared by the backend via `BuildEvent::BuildStarted`.
48    ///
49    /// Populated once up-front. Used by `current_stage_display` so the
50    /// denominator is stable rather than growing as stages arrive.
51    /// `0` means no backend pre-declaration was received; fall back to
52    /// `stages.len()` in that case.
53    pub total_stages: usize,
54    /// Total instructions pre-declared by the backend via
55    /// `BuildEvent::BuildStarted`.
56    ///
57    /// Populated once up-front. Used by `total_instructions` so the
58    /// progress-bar denominator is stable rather than growing as
59    /// `InstructionStarted` events arrive. `0` means no backend
60    /// pre-declaration was received; fall back to the event-sum.
61    pub total_instructions: usize,
62}
63
64/// State of a single build stage
65#[derive(Debug, Clone)]
66pub struct StageState {
67    /// Stage index
68    pub index: usize,
69    /// Optional stage name
70    pub name: Option<String>,
71    /// Base image for this stage
72    pub base_image: String,
73    /// Instructions in this stage
74    pub instructions: Vec<InstructionState>,
75    /// Whether the stage is complete
76    pub complete: bool,
77}
78
79/// State of a single instruction
80#[derive(Debug, Clone)]
81pub struct InstructionState {
82    /// Instruction text
83    pub text: String,
84    /// Current status
85    pub status: InstructionStatus,
86}
87
88impl BuildTui {
89    /// Create a new TUI with an event receiver
90    #[must_use]
91    pub fn new(event_rx: Receiver<BuildEvent>) -> Self {
92        Self {
93            event_rx,
94            state: BuildState::default(),
95            running: true,
96        }
97    }
98
99    /// Run the TUI (blocking)
100    ///
101    /// This will take over the terminal, display the build progress,
102    /// and return when the build completes or the user quits (q key).
103    ///
104    /// # Errors
105    ///
106    /// Returns an error if terminal setup, rendering, or restoration fails.
107    pub fn run(&mut self) -> io::Result<()> {
108        let mut terminal = setup_terminal()?;
109        let result = self.run_loop(&mut terminal);
110        restore_terminal(&mut terminal)?;
111        result
112    }
113
114    /// Main event loop
115    fn run_loop(&mut self, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
116        while self.running {
117            // Process any pending build events
118            self.process_events();
119
120            // Render the UI
121            terminal.draw(|frame| self.render(frame))?;
122
123            // Auto-exit when build completes (success or failure).
124            // The TUI disappears and the caller prints a summary to stdout.
125            if self.state.completed {
126                break;
127            }
128
129            // Handle keyboard input with a short timeout
130            if event::poll(POLL_DURATION)? {
131                if let Event::Key(key) = event::read()? {
132                    if key.kind == KeyEventKind::Press {
133                        self.handle_input(key.code, key.modifiers);
134                    }
135                }
136            }
137        }
138
139        Ok(())
140    }
141
142    /// Process pending build events from the channel
143    fn process_events(&mut self) {
144        loop {
145            match self.event_rx.try_recv() {
146                Ok(event) => self.handle_build_event(event),
147                Err(TryRecvError::Empty) => break,
148                Err(TryRecvError::Disconnected) => {
149                    // Build process has finished sending events
150                    if !self.state.completed {
151                        // If we didn't get a completion event, treat as unexpected end
152                        self.state.completed = true;
153                        if self.state.error.is_none() && self.state.image_id.is_none() {
154                            self.state.error = Some("Build ended unexpectedly".to_string());
155                        }
156                    }
157                    break;
158                }
159            }
160        }
161    }
162
163    /// Handle a single build event
164    fn handle_build_event(&mut self, event: BuildEvent) {
165        match event {
166            BuildEvent::BuildStarted {
167                total_stages,
168                total_instructions,
169            } => {
170                self.state.total_stages = total_stages;
171                self.state.total_instructions = total_instructions;
172            }
173
174            BuildEvent::StageStarted {
175                index,
176                name,
177                base_image,
178            } => {
179                // Ensure we have enough stages
180                while self.state.stages.len() <= index {
181                    self.state.stages.push(StageState {
182                        index: self.state.stages.len(),
183                        name: None,
184                        base_image: String::new(),
185                        instructions: Vec::new(),
186                        complete: false,
187                    });
188                }
189
190                // Update the stage
191                self.state.stages[index] = StageState {
192                    index,
193                    name,
194                    base_image,
195                    instructions: Vec::new(),
196                    complete: false,
197                };
198                self.state.current_stage = index;
199                self.state.current_instruction = 0;
200            }
201
202            BuildEvent::InstructionStarted {
203                stage,
204                index,
205                instruction,
206            } => {
207                if let Some(stage_state) = self.state.stages.get_mut(stage) {
208                    // Ensure we have enough instructions
209                    while stage_state.instructions.len() <= index {
210                        stage_state.instructions.push(InstructionState {
211                            text: String::new(),
212                            status: InstructionStatus::Pending,
213                        });
214                    }
215
216                    // Update the instruction
217                    stage_state.instructions[index] = InstructionState {
218                        text: instruction,
219                        status: InstructionStatus::Running,
220                    };
221                    self.state.current_instruction = index;
222                }
223            }
224
225            BuildEvent::Output { line, is_stderr } => {
226                self.state.output_lines.push(OutputLine {
227                    text: line,
228                    is_stderr,
229                });
230
231                // Auto-scroll to bottom if we were already at the bottom
232                let visible_lines = 10; // Approximate
233                let max_scroll = self.state.output_lines.len().saturating_sub(visible_lines);
234                if self.state.scroll_offset >= max_scroll.saturating_sub(1) {
235                    self.state.scroll_offset =
236                        self.state.output_lines.len().saturating_sub(visible_lines);
237                }
238            }
239
240            BuildEvent::InstructionComplete {
241                stage,
242                index,
243                cached,
244            } => {
245                if let Some(stage_state) = self.state.stages.get_mut(stage) {
246                    if let Some(inst) = stage_state.instructions.get_mut(index) {
247                        inst.status = InstructionStatus::Complete { cached };
248                    }
249                }
250            }
251
252            BuildEvent::StageComplete { index } => {
253                if let Some(stage_state) = self.state.stages.get_mut(index) {
254                    stage_state.complete = true;
255                }
256            }
257
258            BuildEvent::BuildComplete { image_id } => {
259                self.state.completed = true;
260                self.state.image_id = Some(image_id);
261            }
262
263            BuildEvent::BuildFailed { error } => {
264                self.state.completed = true;
265                self.state.error = Some(error);
266
267                // Mark current instruction as failed
268                if let Some(stage_state) = self.state.stages.get_mut(self.state.current_stage) {
269                    if let Some(inst) = stage_state
270                        .instructions
271                        .get_mut(self.state.current_instruction)
272                    {
273                        if inst.status.is_running() {
274                            inst.status = InstructionStatus::Failed;
275                        }
276                    }
277                }
278            }
279        }
280    }
281
282    /// Handle keyboard input
283    fn handle_input(&mut self, key: KeyCode, modifiers: KeyModifiers) {
284        match key {
285            KeyCode::Char('q') | KeyCode::Esc => {
286                self.running = false;
287            }
288            KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
289                self.running = false;
290            }
291            KeyCode::Up | KeyCode::Char('k') => {
292                self.state.scroll_offset = self.state.scroll_offset.saturating_sub(1);
293            }
294            KeyCode::Down | KeyCode::Char('j') => {
295                let max_scroll = self.state.output_lines.len().saturating_sub(10);
296                if self.state.scroll_offset < max_scroll {
297                    self.state.scroll_offset += 1;
298                }
299            }
300            KeyCode::PageUp => {
301                self.state.scroll_offset = self.state.scroll_offset.saturating_sub(10);
302            }
303            KeyCode::PageDown => {
304                let max_scroll = self.state.output_lines.len().saturating_sub(10);
305                self.state.scroll_offset = (self.state.scroll_offset + 10).min(max_scroll);
306            }
307            KeyCode::Home => {
308                self.state.scroll_offset = 0;
309            }
310            KeyCode::End => {
311                let max_scroll = self.state.output_lines.len().saturating_sub(10);
312                self.state.scroll_offset = max_scroll;
313            }
314            _ => {}
315        }
316    }
317
318    /// Render the UI
319    fn render(&self, frame: &mut Frame) {
320        let view = BuildView::new(&self.state);
321        frame.render_widget(view, frame.area());
322    }
323}
324
325impl BuildState {
326    /// Get the total number of instructions across all stages.
327    ///
328    /// Prefers the pre-declared total from `BuildEvent::BuildStarted`
329    /// (so the progress-bar denominator is stable); falls back to
330    /// summing the stages we've learned about so far for older tests
331    /// or backends that don't emit `BuildStarted`.
332    #[must_use]
333    pub fn total_instructions(&self) -> usize {
334        let event_sum: usize = self.stages.iter().map(|s| s.instructions.len()).sum();
335        self.total_instructions.max(event_sum)
336    }
337
338    /// Get the number of completed instructions across all stages
339    #[must_use]
340    pub fn completed_instructions(&self) -> usize {
341        self.stages
342            .iter()
343            .flat_map(|s| s.instructions.iter())
344            .filter(|i| i.status.is_complete())
345            .count()
346    }
347
348    /// Get a display string for the current stage
349    #[must_use]
350    pub fn current_stage_display(&self) -> String {
351        if let Some(stage) = self.stages.get(self.current_stage) {
352            let name_part = stage
353                .name
354                .as_ref()
355                .map(|n| format!("{n} "))
356                .unwrap_or_default();
357            format!(
358                "Stage {}/{}: {}({})",
359                self.current_stage + 1,
360                self.total_stages.max(self.stages.len()).max(1),
361                name_part,
362                stage.base_image
363            )
364        } else {
365            "Initializing...".to_string()
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use std::sync::mpsc;
374
375    #[test]
376    fn test_build_state_default() {
377        let state = BuildState::default();
378        assert!(state.stages.is_empty());
379        assert!(!state.completed);
380        assert!(state.error.is_none());
381        assert!(state.image_id.is_none());
382    }
383
384    #[test]
385    fn test_build_state_instruction_counts() {
386        let mut state = BuildState::default();
387        state.stages.push(StageState {
388            index: 0,
389            name: None,
390            base_image: "alpine".to_string(),
391            instructions: vec![
392                InstructionState {
393                    text: "RUN echo 1".to_string(),
394                    status: InstructionStatus::Complete { cached: false },
395                },
396                InstructionState {
397                    text: "RUN echo 2".to_string(),
398                    status: InstructionStatus::Running,
399                },
400            ],
401            complete: false,
402        });
403
404        assert_eq!(state.total_instructions(), 2);
405        assert_eq!(state.completed_instructions(), 1);
406    }
407
408    #[test]
409    fn total_instructions_uses_predeclared_total() {
410        // Reproduces the old bug: before this fix, `total_instructions()`
411        // summed stages as `InstructionStarted` events arrived, so the
412        // TUI denominator grew in lockstep with the numerator. With the
413        // up-front `BuildStarted` event, the denominator is stable from
414        // the first instruction onward.
415        let (tx, rx) = mpsc::channel();
416        let mut tui = BuildTui::new(rx);
417
418        tx.send(BuildEvent::BuildStarted {
419            total_stages: 2,
420            total_instructions: 7,
421        })
422        .unwrap();
423
424        tx.send(BuildEvent::StageStarted {
425            index: 0,
426            name: None,
427            base_image: "alpine".to_string(),
428        })
429        .unwrap();
430
431        tx.send(BuildEvent::InstructionStarted {
432            stage: 0,
433            index: 0,
434            instruction: "RUN foo".to_string(),
435        })
436        .unwrap();
437
438        drop(tx);
439        tui.process_events();
440
441        // Pre-declared total wins over the in-progress event-sum of 1.
442        assert_eq!(tui.state.total_instructions(), 7);
443        // Current-stage denominator is also populated up-front.
444        assert!(tui.state.current_stage_display().contains("Stage 1/2"));
445    }
446
447    #[test]
448    fn test_handle_stage_started() {
449        let (tx, rx) = mpsc::channel();
450        let mut tui = BuildTui::new(rx);
451
452        tx.send(BuildEvent::StageStarted {
453            index: 0,
454            name: Some("builder".to_string()),
455            base_image: "node:20".to_string(),
456        })
457        .unwrap();
458
459        drop(tx);
460        tui.process_events();
461
462        assert_eq!(tui.state.stages.len(), 1);
463        assert_eq!(tui.state.stages[0].name, Some("builder".to_string()));
464        assert_eq!(tui.state.stages[0].base_image, "node:20");
465    }
466
467    #[test]
468    fn test_handle_instruction_lifecycle() {
469        let (tx, rx) = mpsc::channel();
470        let mut tui = BuildTui::new(rx);
471
472        // Start stage
473        tx.send(BuildEvent::StageStarted {
474            index: 0,
475            name: None,
476            base_image: "alpine".to_string(),
477        })
478        .unwrap();
479
480        // Start instruction
481        tx.send(BuildEvent::InstructionStarted {
482            stage: 0,
483            index: 0,
484            instruction: "RUN echo hello".to_string(),
485        })
486        .unwrap();
487
488        // Complete instruction
489        tx.send(BuildEvent::InstructionComplete {
490            stage: 0,
491            index: 0,
492            cached: true,
493        })
494        .unwrap();
495
496        drop(tx);
497        tui.process_events();
498
499        let inst = &tui.state.stages[0].instructions[0];
500        assert_eq!(inst.text, "RUN echo hello");
501        assert!(matches!(
502            inst.status,
503            InstructionStatus::Complete { cached: true }
504        ));
505    }
506
507    #[test]
508    fn test_handle_build_complete() {
509        let (tx, rx) = mpsc::channel();
510        let mut tui = BuildTui::new(rx);
511
512        tx.send(BuildEvent::BuildComplete {
513            image_id: "sha256:abc123".to_string(),
514        })
515        .unwrap();
516
517        drop(tx);
518        tui.process_events();
519
520        assert!(tui.state.completed);
521        assert_eq!(tui.state.image_id, Some("sha256:abc123".to_string()));
522        assert!(tui.state.error.is_none());
523    }
524
525    #[test]
526    fn test_handle_build_failed() {
527        let (tx, rx) = mpsc::channel();
528        let mut tui = BuildTui::new(rx);
529
530        // Set up a running instruction first
531        tx.send(BuildEvent::StageStarted {
532            index: 0,
533            name: None,
534            base_image: "alpine".to_string(),
535        })
536        .unwrap();
537
538        tx.send(BuildEvent::InstructionStarted {
539            stage: 0,
540            index: 0,
541            instruction: "RUN exit 1".to_string(),
542        })
543        .unwrap();
544
545        tx.send(BuildEvent::BuildFailed {
546            error: "Command failed with exit code 1".to_string(),
547        })
548        .unwrap();
549
550        drop(tx);
551        tui.process_events();
552
553        assert!(tui.state.completed);
554        assert!(tui.state.error.is_some());
555        assert!(tui.state.stages[0].instructions[0].status.is_failed());
556    }
557}