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 pub fn new(event_rx: Receiver<BuildEvent>) -> Self {
76 Self {
77 event_rx,
78 state: BuildState::default(),
79 running: true,
80 }
81 }
82
83 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 fn run_loop(&mut self, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
96 while self.running {
97 self.process_events();
99
100 terminal.draw(|frame| self.render(frame))?;
102
103 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 if self.state.completed && !self.running {
114 break;
115 }
116 }
117
118 Ok(())
119 }
120
121 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 if !self.state.completed {
130 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 fn handle_build_event(&mut self, event: BuildEvent) {
144 match event {
145 BuildEvent::StageStarted {
146 index,
147 name,
148 base_image,
149 } => {
150 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 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 while stage_state.instructions.len() <= index {
181 stage_state.instructions.push(InstructionState {
182 text: String::new(),
183 status: InstructionStatus::Pending,
184 });
185 }
186
187 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 let visible_lines = 10; 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 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 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 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 pub fn total_instructions(&self) -> usize {
296 self.stages.iter().map(|s| s.instructions.len()).sum()
297 }
298
299 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 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 tx.send(BuildEvent::StageStarted {
394 index: 0,
395 name: None,
396 base_image: "alpine".to_string(),
397 })
398 .unwrap();
399
400 tx.send(BuildEvent::InstructionStarted {
402 stage: 0,
403 index: 0,
404 instruction: "RUN echo hello".to_string(),
405 })
406 .unwrap();
407
408 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 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}