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};
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            // Handle keyboard input with a short timeout
109            if event::poll(POLL_DURATION)? {
110                if let Event::Key(key) = event::read()? {
111                    if key.kind == KeyEventKind::Press {
112                        self.handle_input(key.code);
113                    }
114                }
115            }
116
117            // Stop if build completed and user acknowledged
118            if self.state.completed && !self.running {
119                break;
120            }
121        }
122
123        Ok(())
124    }
125
126    /// Process pending build events from the channel
127    fn process_events(&mut self) {
128        loop {
129            match self.event_rx.try_recv() {
130                Ok(event) => self.handle_build_event(event),
131                Err(TryRecvError::Empty) => break,
132                Err(TryRecvError::Disconnected) => {
133                    // Build process has finished sending events
134                    if !self.state.completed {
135                        // If we didn't get a completion event, treat as unexpected end
136                        self.state.completed = true;
137                        if self.state.error.is_none() && self.state.image_id.is_none() {
138                            self.state.error = Some("Build ended unexpectedly".to_string());
139                        }
140                    }
141                    break;
142                }
143            }
144        }
145    }
146
147    /// Handle a single build event
148    fn handle_build_event(&mut self, event: BuildEvent) {
149        match event {
150            BuildEvent::StageStarted {
151                index,
152                name,
153                base_image,
154            } => {
155                // Ensure we have enough stages
156                while self.state.stages.len() <= index {
157                    self.state.stages.push(StageState {
158                        index: self.state.stages.len(),
159                        name: None,
160                        base_image: String::new(),
161                        instructions: Vec::new(),
162                        complete: false,
163                    });
164                }
165
166                // Update the stage
167                self.state.stages[index] = StageState {
168                    index,
169                    name,
170                    base_image,
171                    instructions: Vec::new(),
172                    complete: false,
173                };
174                self.state.current_stage = index;
175                self.state.current_instruction = 0;
176            }
177
178            BuildEvent::InstructionStarted {
179                stage,
180                index,
181                instruction,
182            } => {
183                if let Some(stage_state) = self.state.stages.get_mut(stage) {
184                    // Ensure we have enough instructions
185                    while stage_state.instructions.len() <= index {
186                        stage_state.instructions.push(InstructionState {
187                            text: String::new(),
188                            status: InstructionStatus::Pending,
189                        });
190                    }
191
192                    // Update the instruction
193                    stage_state.instructions[index] = InstructionState {
194                        text: instruction,
195                        status: InstructionStatus::Running,
196                    };
197                    self.state.current_instruction = index;
198                }
199            }
200
201            BuildEvent::Output { line, is_stderr } => {
202                self.state.output_lines.push(OutputLine {
203                    text: line,
204                    is_stderr,
205                });
206
207                // Auto-scroll to bottom if we were already at the bottom
208                let visible_lines = 10; // Approximate
209                let max_scroll = self.state.output_lines.len().saturating_sub(visible_lines);
210                if self.state.scroll_offset >= max_scroll.saturating_sub(1) {
211                    self.state.scroll_offset =
212                        self.state.output_lines.len().saturating_sub(visible_lines);
213                }
214            }
215
216            BuildEvent::InstructionComplete {
217                stage,
218                index,
219                cached,
220            } => {
221                if let Some(stage_state) = self.state.stages.get_mut(stage) {
222                    if let Some(inst) = stage_state.instructions.get_mut(index) {
223                        inst.status = InstructionStatus::Complete { cached };
224                    }
225                }
226            }
227
228            BuildEvent::StageComplete { index } => {
229                if let Some(stage_state) = self.state.stages.get_mut(index) {
230                    stage_state.complete = true;
231                }
232            }
233
234            BuildEvent::BuildComplete { image_id } => {
235                self.state.completed = true;
236                self.state.image_id = Some(image_id);
237            }
238
239            BuildEvent::BuildFailed { error } => {
240                self.state.completed = true;
241                self.state.error = Some(error);
242
243                // Mark current instruction as failed
244                if let Some(stage_state) = self.state.stages.get_mut(self.state.current_stage) {
245                    if let Some(inst) = stage_state
246                        .instructions
247                        .get_mut(self.state.current_instruction)
248                    {
249                        if inst.status.is_running() {
250                            inst.status = InstructionStatus::Failed;
251                        }
252                    }
253                }
254            }
255        }
256    }
257
258    /// Handle keyboard input
259    fn handle_input(&mut self, key: KeyCode) {
260        match key {
261            KeyCode::Char('q') | KeyCode::Esc => {
262                self.running = false;
263            }
264            KeyCode::Up | KeyCode::Char('k') => {
265                self.state.scroll_offset = self.state.scroll_offset.saturating_sub(1);
266            }
267            KeyCode::Down | KeyCode::Char('j') => {
268                let max_scroll = self.state.output_lines.len().saturating_sub(10);
269                if self.state.scroll_offset < max_scroll {
270                    self.state.scroll_offset += 1;
271                }
272            }
273            KeyCode::PageUp => {
274                self.state.scroll_offset = self.state.scroll_offset.saturating_sub(10);
275            }
276            KeyCode::PageDown => {
277                let max_scroll = self.state.output_lines.len().saturating_sub(10);
278                self.state.scroll_offset = (self.state.scroll_offset + 10).min(max_scroll);
279            }
280            KeyCode::Home => {
281                self.state.scroll_offset = 0;
282            }
283            KeyCode::End => {
284                let max_scroll = self.state.output_lines.len().saturating_sub(10);
285                self.state.scroll_offset = max_scroll;
286            }
287            _ => {}
288        }
289    }
290
291    /// Render the UI
292    fn render(&self, frame: &mut Frame) {
293        let view = BuildView::new(&self.state);
294        frame.render_widget(view, frame.area());
295    }
296}
297
298impl BuildState {
299    /// Get the total number of instructions across all stages
300    #[must_use]
301    pub fn total_instructions(&self) -> usize {
302        self.stages.iter().map(|s| s.instructions.len()).sum()
303    }
304
305    /// Get the number of completed instructions across all stages
306    #[must_use]
307    pub fn completed_instructions(&self) -> usize {
308        self.stages
309            .iter()
310            .flat_map(|s| s.instructions.iter())
311            .filter(|i| i.status.is_complete())
312            .count()
313    }
314
315    /// Get a display string for the current stage
316    #[must_use]
317    pub fn current_stage_display(&self) -> String {
318        if let Some(stage) = self.stages.get(self.current_stage) {
319            let name_part = stage
320                .name
321                .as_ref()
322                .map(|n| format!("{n} "))
323                .unwrap_or_default();
324            format!(
325                "Stage {}/{}: {}({})",
326                self.current_stage + 1,
327                self.stages.len().max(1),
328                name_part,
329                stage.base_image
330            )
331        } else {
332            "Initializing...".to_string()
333        }
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use std::sync::mpsc;
341
342    #[test]
343    fn test_build_state_default() {
344        let state = BuildState::default();
345        assert!(state.stages.is_empty());
346        assert!(!state.completed);
347        assert!(state.error.is_none());
348        assert!(state.image_id.is_none());
349    }
350
351    #[test]
352    fn test_build_state_instruction_counts() {
353        let mut state = BuildState::default();
354        state.stages.push(StageState {
355            index: 0,
356            name: None,
357            base_image: "alpine".to_string(),
358            instructions: vec![
359                InstructionState {
360                    text: "RUN echo 1".to_string(),
361                    status: InstructionStatus::Complete { cached: false },
362                },
363                InstructionState {
364                    text: "RUN echo 2".to_string(),
365                    status: InstructionStatus::Running,
366                },
367            ],
368            complete: false,
369        });
370
371        assert_eq!(state.total_instructions(), 2);
372        assert_eq!(state.completed_instructions(), 1);
373    }
374
375    #[test]
376    fn test_handle_stage_started() {
377        let (tx, rx) = mpsc::channel();
378        let mut tui = BuildTui::new(rx);
379
380        tx.send(BuildEvent::StageStarted {
381            index: 0,
382            name: Some("builder".to_string()),
383            base_image: "node:20".to_string(),
384        })
385        .unwrap();
386
387        drop(tx);
388        tui.process_events();
389
390        assert_eq!(tui.state.stages.len(), 1);
391        assert_eq!(tui.state.stages[0].name, Some("builder".to_string()));
392        assert_eq!(tui.state.stages[0].base_image, "node:20");
393    }
394
395    #[test]
396    fn test_handle_instruction_lifecycle() {
397        let (tx, rx) = mpsc::channel();
398        let mut tui = BuildTui::new(rx);
399
400        // Start stage
401        tx.send(BuildEvent::StageStarted {
402            index: 0,
403            name: None,
404            base_image: "alpine".to_string(),
405        })
406        .unwrap();
407
408        // Start instruction
409        tx.send(BuildEvent::InstructionStarted {
410            stage: 0,
411            index: 0,
412            instruction: "RUN echo hello".to_string(),
413        })
414        .unwrap();
415
416        // Complete instruction
417        tx.send(BuildEvent::InstructionComplete {
418            stage: 0,
419            index: 0,
420            cached: true,
421        })
422        .unwrap();
423
424        drop(tx);
425        tui.process_events();
426
427        let inst = &tui.state.stages[0].instructions[0];
428        assert_eq!(inst.text, "RUN echo hello");
429        assert!(matches!(
430            inst.status,
431            InstructionStatus::Complete { cached: true }
432        ));
433    }
434
435    #[test]
436    fn test_handle_build_complete() {
437        let (tx, rx) = mpsc::channel();
438        let mut tui = BuildTui::new(rx);
439
440        tx.send(BuildEvent::BuildComplete {
441            image_id: "sha256:abc123".to_string(),
442        })
443        .unwrap();
444
445        drop(tx);
446        tui.process_events();
447
448        assert!(tui.state.completed);
449        assert_eq!(tui.state.image_id, Some("sha256:abc123".to_string()));
450        assert!(tui.state.error.is_none());
451    }
452
453    #[test]
454    fn test_handle_build_failed() {
455        let (tx, rx) = mpsc::channel();
456        let mut tui = BuildTui::new(rx);
457
458        // Set up a running instruction first
459        tx.send(BuildEvent::StageStarted {
460            index: 0,
461            name: None,
462            base_image: "alpine".to_string(),
463        })
464        .unwrap();
465
466        tx.send(BuildEvent::InstructionStarted {
467            stage: 0,
468            index: 0,
469            instruction: "RUN exit 1".to_string(),
470        })
471        .unwrap();
472
473        tx.send(BuildEvent::BuildFailed {
474            error: "Command failed with exit code 1".to_string(),
475        })
476        .unwrap();
477
478        drop(tx);
479        tui.process_events();
480
481        assert!(tui.state.completed);
482        assert!(tui.state.error.is_some());
483        assert!(tui.state.stages[0].instructions[0].status.is_failed());
484    }
485}