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