1use 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
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 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 if self.state.completed && !self.running {
119 break;
120 }
121 }
122
123 Ok(())
124 }
125
126 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 if !self.state.completed {
135 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 fn handle_build_event(&mut self, event: BuildEvent) {
149 match event {
150 BuildEvent::StageStarted {
151 index,
152 name,
153 base_image,
154 } => {
155 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 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 while stage_state.instructions.len() <= index {
186 stage_state.instructions.push(InstructionState {
187 text: String::new(),
188 status: InstructionStatus::Pending,
189 });
190 }
191
192 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 let visible_lines = 10; 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 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 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 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 #[must_use]
301 pub fn total_instructions(&self) -> usize {
302 self.stages.iter().map(|s| s.instructions.len()).sum()
303 }
304
305 #[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 #[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 tx.send(BuildEvent::StageStarted {
402 index: 0,
403 name: None,
404 base_image: "alpine".to_string(),
405 })
406 .unwrap();
407
408 tx.send(BuildEvent::InstructionStarted {
410 stage: 0,
411 index: 0,
412 instruction: "RUN echo hello".to_string(),
413 })
414 .unwrap();
415
416 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 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}