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