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, PlannedStage};
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::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 while stage_state.instructions.len() <= index {
194 stage_state.instructions.push(InstructionState {
195 text: String::new(),
196 status: InstructionStatus::Pending,
197 });
198 }
199
200 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 let visible_lines = 10; 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 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 fn handle_stage_started(&mut self, index: usize, name: Option<String>, base_image: String) {
274 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 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 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 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 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 #[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 #[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 #[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 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 assert_eq!(tui.state.total_instructions(), 7);
501 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 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 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 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 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 tx.send(BuildEvent::StageStarted {
651 index: 0,
652 name: None,
653 base_image: "alpine".to_string(),
654 })
655 .unwrap();
656
657 tx.send(BuildEvent::InstructionStarted {
659 stage: 0,
660 index: 0,
661 instruction: "RUN echo hello".to_string(),
662 })
663 .unwrap();
664
665 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 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}