1use 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
18pub struct BuildTui {
20 event_rx: Receiver<BuildEvent>,
22 state: BuildState,
24 running: bool,
26}
27
28#[derive(Debug, Default)]
30pub struct BuildState {
31 pub stages: Vec<StageState>,
33 pub current_stage: usize,
35 pub current_instruction: usize,
37 pub output_lines: Vec<OutputLine>,
39 pub scroll_offset: usize,
41 pub completed: bool,
43 pub error: Option<String>,
45 pub image_id: Option<String>,
47 pub total_stages: usize,
54 pub total_instructions: usize,
62}
63
64#[derive(Debug, Clone)]
66pub struct StageState {
67 pub index: usize,
69 pub name: Option<String>,
71 pub base_image: String,
73 pub instructions: Vec<InstructionState>,
75 pub complete: bool,
77}
78
79#[derive(Debug, Clone)]
81pub struct InstructionState {
82 pub text: String,
84 pub status: InstructionStatus,
86}
87
88impl BuildTui {
89 #[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 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 fn run_loop(&mut self, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
116 while self.running {
117 self.process_events();
119
120 terminal.draw(|frame| self.render(frame))?;
122
123 if self.state.completed {
126 break;
127 }
128
129 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 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 if !self.state.completed {
151 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 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 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 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 while stage_state.instructions.len() <= index {
210 stage_state.instructions.push(InstructionState {
211 text: String::new(),
212 status: InstructionStatus::Pending,
213 });
214 }
215
216 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 let visible_lines = 10; 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 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 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 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 #[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 #[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 #[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 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 assert_eq!(tui.state.total_instructions(), 7);
443 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 tx.send(BuildEvent::StageStarted {
474 index: 0,
475 name: None,
476 base_image: "alpine".to_string(),
477 })
478 .unwrap();
479
480 tx.send(BuildEvent::InstructionStarted {
482 stage: 0,
483 index: 0,
484 instruction: "RUN echo hello".to_string(),
485 })
486 .unwrap();
487
488 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 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}