1mod app;
8mod draw;
9mod event;
10mod probe_job;
11mod prompt;
12mod tabs;
13
14use anyhow::Result;
15use clap::Parser;
16use crossterm::{
17 cursor::{Hide, Show},
18 event::{self as cterm_event, Event, KeyCode, KeyEventKind},
19 execute,
20 terminal::{
21 disable_raw_mode, enable_raw_mode, Clear as CtClear, ClearType, EnterAlternateScreen,
22 LeaveAlternateScreen,
23 },
24 ExecutableCommand,
25};
26use ratatui::{backend::CrosstermBackend, Terminal};
27use std::io::{self, stdout, Write as IoWrite};
28use std::panic;
29use std::sync::atomic::{AtomicBool, Ordering};
30
31use app::{App, Cli};
32use draw::ui;
33use event::{ctrl_c_quit, handle_modal_key, handle_screen_key, is_quit};
34
35static STOP: AtomicBool = AtomicBool::new(false);
37
38fn install_signal_handlers() {
39 let _ = ctrlc::set_handler(|| {
40 STOP.store(true, Ordering::SeqCst);
41 });
42}
43
44fn install_panic_hook() {
45 let previous = panic::take_hook();
46 panic::set_hook(Box::new(move |info| {
47 restore_terminal();
48 previous(info);
49 }));
50}
51
52struct TerminalGuard;
54
55impl TerminalGuard {
56 fn enter() -> io::Result<(Self, Terminal<CrosstermBackend<io::Stdout>>)> {
57 enable_raw_mode()?;
58 execute!(
59 stdout(),
60 EnterAlternateScreen,
61 Hide,
62 CtClear(ClearType::All),
63 CtClear(ClearType::Purge)
64 )?;
65 let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
66 terminal.clear()?;
67 Ok((Self, terminal))
68 }
69}
70
71impl Drop for TerminalGuard {
72 fn drop(&mut self) {
73 restore_terminal();
74 }
75}
76
77fn hard_reset_tty() {
79 let mut out = stdout();
80 let _ = write!(
81 out,
82 "\x1b[0m\x1b[?25h\x1b[?1049l\x1b[?47l\x1b[2J\x1b[3J\x1b[H"
83 );
84 let _ = out.execute(CtClear(ClearType::All));
85 let _ = out.execute(CtClear(ClearType::Purge));
86 let _ = out.execute(Show);
87 let _ = out.flush();
88}
89
90fn restore_terminal() {
91 let _ = disable_raw_mode();
92 let mut out = stdout();
93 let _ = out.execute(LeaveAlternateScreen);
94 let _ = out.flush();
95 hard_reset_tty();
96}
97
98fn main() -> Result<()> {
99 let cli = Cli::parse();
100 install_signal_handlers();
101 install_panic_hook();
102 let (_guard, mut terminal) = TerminalGuard::enter()?;
103 let mut app = App::new(&cli);
104 run_app(&mut terminal, &mut app)
105}
106
107fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> Result<()> {
108 let mut boot_remote_probe = app.is_remote();
109 loop {
110 if STOP.load(Ordering::SeqCst) {
111 return Ok(());
112 }
113 app.poll_probe();
114 app.poll_reboot();
115 app.poll_fetch();
116 terminal.draw(|f| ui(f, app))?;
117 if boot_remote_probe {
118 app.start_remote_probe();
119 boot_remote_probe = false;
120 }
121 if !cterm_event::poll(std::time::Duration::from_millis(200))? {
122 continue;
123 }
124 let Event::Key(key) = cterm_event::read()? else {
125 continue;
126 };
127 if key.kind != KeyEventKind::Press {
128 continue;
129 }
130 if ctrl_c_quit(key) || is_quit(key) {
131 return Ok(());
132 }
133 if handle_modal_key(app, key) {
134 continue;
135 }
136 if app.help_open {
137 if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
138 app.help_open = false;
139 }
140 continue;
141 }
142 if key.code == KeyCode::Esc {
143 return Ok(());
144 }
145 handle_screen_key(app, key);
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::app::BoxCliView;
153 use crate::draw::{footer_cli_label, setup_step_line};
154 use clap::CommandFactory;
155 use clap::Parser;
156 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
157 use horto_os_ui_shared::RemoteBoxCliStatus;
158
159 #[test]
160 fn cli_debug_assert() {
161 Cli::command().debug_assert();
162 }
163
164 #[test]
165 fn parses_flags() {
166 let cli = Cli::try_parse_from(["horto-os-ui-tui", "--apply", "--minimal", "--skip-piper"])
167 .unwrap();
168 assert!(cli.apply);
169 assert!(cli.minimal);
170 assert!(cli.skip_piper);
171 }
172
173 #[test]
174 fn quit_keys() {
175 let q = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
176 let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
177 let plain_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
178 assert!(is_quit(q));
179 assert!(is_quit(ctrl_c));
180 assert!(!is_quit(plain_c));
181 }
182
183 #[test]
184 fn footer_cli_label_probing_not_missing() {
185 let label = footer_cli_label("0.1.0 (abc)", true, &BoxCliView::Probing);
186 assert_eq!(label, "local=0.1.0 (abc) box=probing...");
187 assert!(!label.contains('?'));
188 assert!(!label.contains("missing"));
189 }
190
191 #[test]
192 fn footer_cli_label_known_statuses() {
193 assert_eq!(
194 footer_cli_label(
195 "0.1.0",
196 true,
197 &BoxCliView::Known(RemoteBoxCliStatus::Missing)
198 ),
199 "local=0.1.0 box=missing"
200 );
201 assert_eq!(
202 footer_cli_label(
203 "0.1.0",
204 true,
205 &BoxCliView::Known(RemoteBoxCliStatus::AuthFailed)
206 ),
207 "local=0.1.0 box=auth failed"
208 );
209 assert_eq!(
210 footer_cli_label(
211 "0.1.0",
212 true,
213 &BoxCliView::Known(RemoteBoxCliStatus::Unreachable)
214 ),
215 "local=0.1.0 box=unreachable"
216 );
217 assert_eq!(
218 footer_cli_label(
219 "0.1.0",
220 true,
221 &BoxCliView::Known(RemoteBoxCliStatus::Found("0.1.0 (deadbeef)".into()))
222 ),
223 "local=0.1.0 box=0.1.0 (deadbeef)"
224 );
225 assert_eq!(
226 footer_cli_label("0.1.0", false, &BoxCliView::Probing),
227 "local=0.1.0"
228 );
229 }
230
231 #[test]
232 fn remote_app_starts_with_box_probing() {
233 let cli = Cli::try_parse_from(["horto-os-ui-tui", "--remote", "horto"]).unwrap();
234 let app = App::new(&cli);
235 assert_eq!(app.box_cli, BoxCliView::Probing);
236 assert!(app.s0_line().contains("| probing |"));
237 assert!(!app.cli_current);
238 }
239
240 #[test]
241 fn setup_step_line_splits_id_status_title() {
242 let line = setup_step_line("s0 | pending | Sync CLI to box");
243 assert_eq!(line.spans.len(), 5);
244 assert_eq!(line.spans[0].content.as_ref(), "s0");
245 assert_eq!(line.spans[2].content.as_ref(), "pending");
246 assert_eq!(line.spans[4].content.as_ref(), "Sync CLI to box");
247 }
248}