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, PlannedStage};
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::BuildPlan { stages } => {
175                self.populate_from_plan(stages);
176            }
177
178            BuildEvent::StageStarted {
179                index,
180                name,
181                base_image,
182            } => {
183                self.handle_stage_started(index, name, base_image);
184            }
185
186            BuildEvent::InstructionStarted {
187                stage,
188                index,
189                instruction,
190            } => {
191                if let Some(stage_state) = self.state.stages.get_mut(stage) {
192                    // Ensure we have enough instructions
193                    while stage_state.instructions.len() <= index {
194                        stage_state.instructions.push(InstructionState {
195                            text: String::new(),
196                            status: InstructionStatus::Pending,
197                        });
198                    }
199
200                    // Update the instruction
201                    stage_state.instructions[index] = InstructionState {
202                        text: instruction,
203                        status: InstructionStatus::Running,
204                    };
205                    self.state.current_instruction = index;
206                }
207            }
208
209            BuildEvent::Output { line, is_stderr } => {
210                self.state.output_lines.push(OutputLine {
211                    text: line,
212                    is_stderr,
213                });
214
215                // Auto-scroll to bottom if we were already at the bottom
216                let visible_lines = 10; // Approximate
217                let max_scroll = self.state.output_lines.len().saturating_sub(visible_lines);
218                if self.state.scroll_offset >= max_scroll.saturating_sub(1) {
219                    self.state.scroll_offset =
220                        self.state.output_lines.len().saturating_sub(visible_lines);
221                }
222            }
223
224            BuildEvent::InstructionComplete {
225                stage,
226                index,
227                cached,
228            } => {
229                if let Some(stage_state) = self.state.stages.get_mut(stage) {
230                    if let Some(inst) = stage_state.instructions.get_mut(index) {
231                        inst.status = InstructionStatus::Complete { cached };
232                    }
233                }
234            }
235
236            BuildEvent::StageComplete { index } => {
237                if let Some(stage_state) = self.state.stages.get_mut(index) {
238                    stage_state.complete = true;
239                }
240            }
241
242            BuildEvent::BuildComplete { image_id } => {
243                self.state.completed = true;
244                self.state.image_id = Some(image_id);
245            }
246
247            BuildEvent::BuildFailed { error } => {
248                self.state.completed = true;
249                self.state.error = Some(error);
250
251                // Mark current instruction as failed
252                if let Some(stage_state) = self.state.stages.get_mut(self.state.current_stage) {
253                    if let Some(inst) = stage_state
254                        .instructions
255                        .get_mut(self.state.current_instruction)
256                    {
257                        if inst.status.is_running() {
258                            inst.status = InstructionStatus::Failed;
259                        }
260                    }
261                }
262            }
263        }
264    }
265
266    /// Handle a [`BuildEvent::StageStarted`]: (re)create the stage at `index`
267    /// with an empty instruction list and make it current.
268    ///
269    /// Note: this resets the stage's instructions to empty, which would wipe a
270    /// plan pre-filled by [`BuildEvent::BuildPlan`]. The buildah sidecar
271    /// therefore does NOT emit `StageStarted` after a `BuildPlan`; only the
272    /// native backend (which never sends `BuildPlan`) does.
273    fn handle_stage_started(&mut self, index: usize, name: Option<String>, base_image: String) {
274        // Ensure we have enough stages
275        while self.state.stages.len() <= index {
276            self.state.stages.push(StageState {
277                index: self.state.stages.len(),
278                name: None,
279                base_image: String::new(),
280                instructions: Vec::new(),
281                complete: false,
282            });
283        }
284
285        // Update the stage
286        self.state.stages[index] = StageState {
287            index,
288            name,
289            base_image,
290            instructions: Vec::new(),
291            complete: false,
292        };
293        self.state.current_stage = index;
294        self.state.current_instruction = 0;
295    }
296
297    /// Pre-populate the state's stages/instructions from a [`BuildEvent::BuildPlan`].
298    ///
299    /// One [`StageState`] per planned stage (in order, named, with its base
300    /// image and `complete: false`); each planned instruction becomes a
301    /// `Pending` [`InstructionState`]. `total_stages`/`total_instructions`
302    /// are seeded from the plan only if still `0`. After this, the sidecar
303    /// will NOT send `StageStarted` (which would wipe a stage's instructions);
304    /// it only sends `InstructionStarted`/`InstructionComplete`, whose
305    /// existing handlers update `instructions[index]` in place.
306    fn populate_from_plan(&mut self, stages: Vec<PlannedStage>) {
307        let plan_stage_count = stages.len();
308        let plan_instruction_count: usize = stages.iter().map(|s| s.instructions.len()).sum();
309
310        self.state.stages = stages
311            .into_iter()
312            .enumerate()
313            .map(|(index, planned)| StageState {
314                index,
315                name: planned.name,
316                base_image: planned.base_image,
317                instructions: planned
318                    .instructions
319                    .into_iter()
320                    .map(|text| InstructionState {
321                        text,
322                        status: InstructionStatus::Pending,
323                    })
324                    .collect(),
325                complete: false,
326            })
327            .collect();
328
329        if self.state.total_stages == 0 {
330            self.state.total_stages = plan_stage_count;
331        }
332        if self.state.total_instructions == 0 {
333            self.state.total_instructions = plan_instruction_count;
334        }
335
336        self.state.current_stage = 0;
337        self.state.current_instruction = 0;
338    }
339
340    /// Handle keyboard input
341    fn handle_input(&mut self, key: KeyCode, modifiers: KeyModifiers) {
342        match key {
343            KeyCode::Char('q') | KeyCode::Esc => {
344                self.running = false;
345            }
346            KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
347                self.running = false;
348            }
349            KeyCode::Up | KeyCode::Char('k') => {
350                self.state.scroll_offset = self.state.scroll_offset.saturating_sub(1);
351            }
352            KeyCode::Down | KeyCode::Char('j') => {
353                let max_scroll = self.state.output_lines.len().saturating_sub(10);
354                if self.state.scroll_offset < max_scroll {
355                    self.state.scroll_offset += 1;
356                }
357            }
358            KeyCode::PageUp => {
359                self.state.scroll_offset = self.state.scroll_offset.saturating_sub(10);
360            }
361            KeyCode::PageDown => {
362                let max_scroll = self.state.output_lines.len().saturating_sub(10);
363                self.state.scroll_offset = (self.state.scroll_offset + 10).min(max_scroll);
364            }
365            KeyCode::Home => {
366                self.state.scroll_offset = 0;
367            }
368            KeyCode::End => {
369                let max_scroll = self.state.output_lines.len().saturating_sub(10);
370                self.state.scroll_offset = max_scroll;
371            }
372            _ => {}
373        }
374    }
375
376    /// Render the UI
377    fn render(&self, frame: &mut Frame) {
378        let view = BuildView::new(&self.state);
379        frame.render_widget(view, frame.area());
380    }
381}
382
383impl BuildState {
384    /// Get the total number of instructions across all stages.
385    ///
386    /// Prefers the pre-declared total from `BuildEvent::BuildStarted`
387    /// (so the progress-bar denominator is stable); falls back to
388    /// summing the stages we've learned about so far for older tests
389    /// or backends that don't emit `BuildStarted`.
390    #[must_use]
391    pub fn total_instructions(&self) -> usize {
392        let event_sum: usize = self.stages.iter().map(|s| s.instructions.len()).sum();
393        self.total_instructions.max(event_sum)
394    }
395
396    /// Get the number of completed instructions across all stages
397    #[must_use]
398    pub fn completed_instructions(&self) -> usize {
399        self.stages
400            .iter()
401            .flat_map(|s| s.instructions.iter())
402            .filter(|i| i.status.is_complete())
403            .count()
404    }
405
406    /// Get a display string for the current stage
407    #[must_use]
408    pub fn current_stage_display(&self) -> String {
409        if let Some(stage) = self.stages.get(self.current_stage) {
410            let name_part = stage
411                .name
412                .as_ref()
413                .map(|n| format!("{n} "))
414                .unwrap_or_default();
415            format!(
416                "Stage {}/{}: {}({})",
417                self.current_stage + 1,
418                self.total_stages.max(self.stages.len()).max(1),
419                name_part,
420                stage.base_image
421            )
422        } else {
423            "Initializing...".to_string()
424        }
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use std::sync::mpsc;
432
433    #[test]
434    fn test_build_state_default() {
435        let state = BuildState::default();
436        assert!(state.stages.is_empty());
437        assert!(!state.completed);
438        assert!(state.error.is_none());
439        assert!(state.image_id.is_none());
440    }
441
442    #[test]
443    fn test_build_state_instruction_counts() {
444        let mut state = BuildState::default();
445        state.stages.push(StageState {
446            index: 0,
447            name: None,
448            base_image: "alpine".to_string(),
449            instructions: vec![
450                InstructionState {
451                    text: "RUN echo 1".to_string(),
452                    status: InstructionStatus::Complete { cached: false },
453                },
454                InstructionState {
455                    text: "RUN echo 2".to_string(),
456                    status: InstructionStatus::Running,
457                },
458            ],
459            complete: false,
460        });
461
462        assert_eq!(state.total_instructions(), 2);
463        assert_eq!(state.completed_instructions(), 1);
464    }
465
466    #[test]
467    fn total_instructions_uses_predeclared_total() {
468        // Reproduces the old bug: before this fix, `total_instructions()`
469        // summed stages as `InstructionStarted` events arrived, so the
470        // TUI denominator grew in lockstep with the numerator. With the
471        // up-front `BuildStarted` event, the denominator is stable from
472        // the first instruction onward.
473        let (tx, rx) = mpsc::channel();
474        let mut tui = BuildTui::new(rx);
475
476        tx.send(BuildEvent::BuildStarted {
477            total_stages: 2,
478            total_instructions: 7,
479        })
480        .unwrap();
481
482        tx.send(BuildEvent::StageStarted {
483            index: 0,
484            name: None,
485            base_image: "alpine".to_string(),
486        })
487        .unwrap();
488
489        tx.send(BuildEvent::InstructionStarted {
490            stage: 0,
491            index: 0,
492            instruction: "RUN foo".to_string(),
493        })
494        .unwrap();
495
496        drop(tx);
497        tui.process_events();
498
499        // Pre-declared total wins over the in-progress event-sum of 1.
500        assert_eq!(tui.state.total_instructions(), 7);
501        // Current-stage denominator is also populated up-front.
502        assert!(tui.state.current_stage_display().contains("Stage 1/2"));
503    }
504
505    #[test]
506    fn build_plan_prefills_pending_instructions() {
507        let (tx, rx) = mpsc::channel();
508        let mut tui = BuildTui::new(rx);
509
510        tx.send(BuildEvent::BuildPlan {
511            stages: vec![
512                PlannedStage {
513                    name: Some("builder".to_string()),
514                    base_image: "node:20-alpine".to_string(),
515                    instructions: vec!["WORKDIR /app".to_string(), "RUN npm ci".to_string()],
516                },
517                PlannedStage {
518                    name: None,
519                    base_image: "alpine".to_string(),
520                    instructions: vec!["COPY --from=builder /app /app".to_string()],
521                },
522            ],
523        })
524        .unwrap();
525
526        drop(tx);
527        tui.process_events();
528
529        // Full plan is present immediately — two stages, three instructions,
530        // all Pending. (This is what unblocks "Waiting for build to start...".)
531        assert_eq!(tui.state.stages.len(), 2);
532        assert_eq!(tui.state.total_stages, 2);
533        assert_eq!(tui.state.total_instructions(), 3);
534        assert_eq!(tui.state.completed_instructions(), 0);
535        assert!(tui
536            .state
537            .stages
538            .iter()
539            .flat_map(|s| &s.instructions)
540            .all(|i| matches!(i.status, InstructionStatus::Pending)));
541        assert_eq!(tui.state.stages[0].instructions[0].text, "WORKDIR /app");
542    }
543
544    #[test]
545    fn build_plan_then_progress_advances_statuses() {
546        // Mirrors what the sidecar backend emits: a BuildPlan, then a
547        // sequence of InstructionStarted/InstructionComplete derived from
548        // buildah's `--> ` commit markers. Statuses must advance
549        // Pending -> Running -> Complete and the counter must track.
550        let (tx, rx) = mpsc::channel();
551        let mut tui = BuildTui::new(rx);
552
553        tx.send(BuildEvent::BuildPlan {
554            stages: vec![PlannedStage {
555                name: None,
556                base_image: "alpine".to_string(),
557                instructions: vec!["RUN echo a".to_string(), "RUN echo b".to_string()],
558            }],
559        })
560        .unwrap();
561
562        // Sidecar marks the first instruction Running up-front.
563        tx.send(BuildEvent::InstructionStarted {
564            stage: 0,
565            index: 0,
566            instruction: "RUN echo a".to_string(),
567        })
568        .unwrap();
569
570        drop(tx);
571        tui.process_events();
572
573        assert!(tui.state.stages[0].instructions[0].status.is_running());
574        assert!(matches!(
575            tui.state.stages[0].instructions[1].status,
576            InstructionStatus::Pending
577        ));
578        assert_eq!(tui.state.completed_instructions(), 0);
579
580        // First `--> ` marker: complete inst 0, start inst 1.
581        let (tx, rx) = mpsc::channel();
582        let mut tui2 = BuildTui::new(rx);
583        tx.send(BuildEvent::BuildPlan {
584            stages: vec![PlannedStage {
585                name: None,
586                base_image: "alpine".to_string(),
587                instructions: vec!["RUN echo a".to_string(), "RUN echo b".to_string()],
588            }],
589        })
590        .unwrap();
591        tx.send(BuildEvent::InstructionStarted {
592            stage: 0,
593            index: 0,
594            instruction: "RUN echo a".to_string(),
595        })
596        .unwrap();
597        tx.send(BuildEvent::InstructionComplete {
598            stage: 0,
599            index: 0,
600            cached: false,
601        })
602        .unwrap();
603        tx.send(BuildEvent::InstructionStarted {
604            stage: 0,
605            index: 1,
606            instruction: "RUN echo b".to_string(),
607        })
608        .unwrap();
609        tx.send(BuildEvent::InstructionComplete {
610            stage: 0,
611            index: 1,
612            cached: true,
613        })
614        .unwrap();
615        drop(tx);
616        tui2.process_events();
617
618        assert!(tui2.state.stages[0].instructions[0].status.is_complete());
619        assert!(tui2.state.stages[0].instructions[1].status.is_complete());
620        assert_eq!(tui2.state.completed_instructions(), 2);
621        assert_eq!(tui2.state.total_instructions(), 2);
622    }
623
624    #[test]
625    fn test_handle_stage_started() {
626        let (tx, rx) = mpsc::channel();
627        let mut tui = BuildTui::new(rx);
628
629        tx.send(BuildEvent::StageStarted {
630            index: 0,
631            name: Some("builder".to_string()),
632            base_image: "node:20".to_string(),
633        })
634        .unwrap();
635
636        drop(tx);
637        tui.process_events();
638
639        assert_eq!(tui.state.stages.len(), 1);
640        assert_eq!(tui.state.stages[0].name, Some("builder".to_string()));
641        assert_eq!(tui.state.stages[0].base_image, "node:20");
642    }
643
644    #[test]
645    fn test_handle_instruction_lifecycle() {
646        let (tx, rx) = mpsc::channel();
647        let mut tui = BuildTui::new(rx);
648
649        // Start stage
650        tx.send(BuildEvent::StageStarted {
651            index: 0,
652            name: None,
653            base_image: "alpine".to_string(),
654        })
655        .unwrap();
656
657        // Start instruction
658        tx.send(BuildEvent::InstructionStarted {
659            stage: 0,
660            index: 0,
661            instruction: "RUN echo hello".to_string(),
662        })
663        .unwrap();
664
665        // Complete instruction
666        tx.send(BuildEvent::InstructionComplete {
667            stage: 0,
668            index: 0,
669            cached: true,
670        })
671        .unwrap();
672
673        drop(tx);
674        tui.process_events();
675
676        let inst = &tui.state.stages[0].instructions[0];
677        assert_eq!(inst.text, "RUN echo hello");
678        assert!(matches!(
679            inst.status,
680            InstructionStatus::Complete { cached: true }
681        ));
682    }
683
684    #[test]
685    fn test_handle_build_complete() {
686        let (tx, rx) = mpsc::channel();
687        let mut tui = BuildTui::new(rx);
688
689        tx.send(BuildEvent::BuildComplete {
690            image_id: "sha256:abc123".to_string(),
691        })
692        .unwrap();
693
694        drop(tx);
695        tui.process_events();
696
697        assert!(tui.state.completed);
698        assert_eq!(tui.state.image_id, Some("sha256:abc123".to_string()));
699        assert!(tui.state.error.is_none());
700    }
701
702    #[test]
703    fn test_handle_build_failed() {
704        let (tx, rx) = mpsc::channel();
705        let mut tui = BuildTui::new(rx);
706
707        // Set up a running instruction first
708        tx.send(BuildEvent::StageStarted {
709            index: 0,
710            name: None,
711            base_image: "alpine".to_string(),
712        })
713        .unwrap();
714
715        tx.send(BuildEvent::InstructionStarted {
716            stage: 0,
717            index: 0,
718            instruction: "RUN exit 1".to_string(),
719        })
720        .unwrap();
721
722        tx.send(BuildEvent::BuildFailed {
723            error: "Command failed with exit code 1".to_string(),
724        })
725        .unwrap();
726
727        drop(tx);
728        tui.process_events();
729
730        assert!(tui.state.completed);
731        assert!(tui.state.error.is_some());
732        assert!(tui.state.stages[0].instructions[0].status.is_failed());
733    }
734}