zlayer_builder/tui/mod.rs
1//! Terminal UI for build progress visualization
2//!
3//! This module provides a Ratatui-based TUI for displaying build progress,
4//! as well as a plain logger for CI/non-interactive environments.
5//!
6//! # Architecture
7//!
8//! The TUI is event-driven and communicates with the build process via channels:
9//!
10//! ```text
11//! ┌─────────────────┐ mpsc::Sender<BuildEvent> ┌─────────────────┐
12//! │ Build Process │ ──────────────────────────────▶ │ BuildTui │
13//! └─────────────────┘ └─────────────────┘
14//! ```
15//!
16//! # Example
17//!
18//! ```no_run
19//! use zlayer_builder::tui::{BuildTui, BuildEvent};
20//! use std::sync::mpsc;
21//!
22//! # fn main() -> std::io::Result<()> {
23//! // Create channel for build events
24//! let (tx, rx) = mpsc::channel();
25//!
26//! // Spawn build process that sends events
27//! std::thread::spawn(move || {
28//! tx.send(BuildEvent::StageStarted {
29//! index: 0,
30//! name: Some("builder".to_string()),
31//! base_image: "node:20-alpine".to_string(),
32//! }).unwrap();
33//! // ... more events ...
34//! });
35//!
36//! // Run TUI (blocks until build completes or user quits)
37//! let mut tui = BuildTui::new(rx);
38//! tui.run()?;
39//! # Ok(())
40//! # }
41//! ```
42
43mod app;
44mod build_view;
45mod logger;
46mod widgets;
47
48pub use app::{BuildState, BuildTui, InstructionState, StageState};
49pub use build_view::BuildView;
50pub use logger::PlainLogger;
51pub use zlayer_tui::widgets::scrollable_pane::OutputLine;
52
53/// Build event for TUI updates
54///
55/// These events are sent from the build process to update the TUI state.
56/// The TUI processes these events asynchronously and updates the display.
57#[derive(Debug, Clone)]
58pub enum BuildEvent {
59 /// Starting a new stage
60 StageStarted {
61 /// Stage index (0-based)
62 index: usize,
63 /// Optional stage name (from `AS name`)
64 name: Option<String>,
65 /// Base image for this stage
66 base_image: String,
67 },
68
69 /// Starting an instruction within a stage
70 InstructionStarted {
71 /// Stage index
72 stage: usize,
73 /// Instruction index within the stage
74 index: usize,
75 /// Instruction text (e.g., "RUN npm ci")
76 instruction: String,
77 },
78
79 /// Instruction output (streaming)
80 Output {
81 /// Output line content
82 line: String,
83 /// Whether this is stderr (true) or stdout (false)
84 is_stderr: bool,
85 },
86
87 /// Instruction completed
88 InstructionComplete {
89 /// Stage index
90 stage: usize,
91 /// Instruction index
92 index: usize,
93 /// Whether this instruction was served from cache
94 cached: bool,
95 },
96
97 /// Stage completed
98 StageComplete {
99 /// Stage index
100 index: usize,
101 },
102
103 /// Build complete
104 BuildComplete {
105 /// Final image ID
106 image_id: String,
107 },
108
109 /// Build failed
110 BuildFailed {
111 /// Error message
112 error: String,
113 },
114}
115
116/// Status of an instruction during build
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub enum InstructionStatus {
119 /// Instruction has not started yet
120 #[default]
121 Pending,
122 /// Instruction is currently running
123 Running,
124 /// Instruction completed successfully
125 Complete {
126 /// Whether the result was served from cache
127 cached: bool,
128 },
129 /// Instruction failed
130 Failed,
131}
132
133impl InstructionStatus {
134 /// Returns true if the instruction is complete (successfully or from cache)
135 #[must_use]
136 pub fn is_complete(&self) -> bool {
137 matches!(self, Self::Complete { .. })
138 }
139
140 /// Returns true if the instruction is currently running
141 #[must_use]
142 pub fn is_running(&self) -> bool {
143 matches!(self, Self::Running)
144 }
145
146 /// Returns true if the instruction failed
147 #[must_use]
148 pub fn is_failed(&self) -> bool {
149 matches!(self, Self::Failed)
150 }
151
152 /// Returns the status indicator character
153 #[must_use]
154 pub fn indicator(&self) -> char {
155 match self {
156 Self::Pending => '\u{25CB}', // ○
157 Self::Running => '\u{25B6}', // ▶
158 Self::Complete { .. } => '\u{2713}', // ✓
159 Self::Failed => '\u{2717}', // ✗
160 }
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn test_instruction_status_indicator() {
170 assert_eq!(InstructionStatus::Pending.indicator(), '\u{25CB}');
171 assert_eq!(InstructionStatus::Running.indicator(), '\u{25B6}');
172 assert_eq!(
173 InstructionStatus::Complete { cached: false }.indicator(),
174 '\u{2713}'
175 );
176 assert_eq!(InstructionStatus::Failed.indicator(), '\u{2717}');
177 }
178
179 #[test]
180 fn test_instruction_status_states() {
181 assert!(!InstructionStatus::Pending.is_complete());
182 assert!(!InstructionStatus::Pending.is_running());
183 assert!(!InstructionStatus::Pending.is_failed());
184
185 assert!(!InstructionStatus::Running.is_complete());
186 assert!(InstructionStatus::Running.is_running());
187 assert!(!InstructionStatus::Running.is_failed());
188
189 assert!(InstructionStatus::Complete { cached: false }.is_complete());
190 assert!(!InstructionStatus::Complete { cached: true }.is_running());
191 assert!(!InstructionStatus::Complete { cached: false }.is_failed());
192
193 assert!(!InstructionStatus::Failed.is_complete());
194 assert!(!InstructionStatus::Failed.is_running());
195 assert!(InstructionStatus::Failed.is_failed());
196 }
197}