1use std::io::Write;
2use std::path::PathBuf;
3use std::sync::{Arc, Mutex, PoisonError};
4use std::thread::JoinHandle;
5
6use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7use log::info;
8use portable_pty::{
9 ChildKiller, CommandBuilder, ExitStatus, MasterPty, NativePtySystem, PtySize, PtySystem,
10};
11use ratatui::prelude::*;
12use ratatui::symbols::border;
13use ratatui::widgets::{Block, Clear, Widget};
14use tui_term::widget::PseudoTerminal;
15use vt100_ctt::Parser;
16
17use crate::error::{CarchError, Result};
18use crate::ui::theme::Theme;
19
20const SCROLLBACK_LEN: usize = 1000;
21const PAGE_STEP: usize = 10;
22
23type ChildKillerSender = oneshot::Sender<Option<Box<dyn ChildKiller + Send + Sync>>>;
24type ChildKillerReceiver = oneshot::Receiver<Option<Box<dyn ChildKiller + Send + Sync>>>;
25
26pub enum PopupEvent {
27 Close,
28 None,
29}
30
31pub struct RunScriptPopup {
32 buffer: Arc<Mutex<Vec<u8>>>,
33 command_thread: Option<JoinHandle<ExitStatus>>,
34 child_killer: Option<ChildKillerReceiver>,
35 pty_master: Box<dyn MasterPty + Send>,
36 writer: Box<dyn Write + Send>,
37 status: Option<ExitStatus>,
38 parser: Option<Parser>,
39 parser_size: (u16, u16),
40 pty_size: (u16, u16),
41 processed_len: usize,
42 last_max_check: usize,
43 scroll_offset: usize,
44 actual_max: usize,
45 auto_scroll: bool,
46 theme: Theme,
47 was_finished: bool,
48 log_mode: bool,
49 log_path: Option<String>,
50}
51
52impl RunScriptPopup {
53 pub fn new(
54 script_path: PathBuf,
55 log_mode: bool,
56 theme: Theme,
57 log_path: Option<String>,
58 ) -> Result<Self> {
59 let pty_system = NativePtySystem::default();
60
61 let mut cmd = CommandBuilder::new("bash");
62 cmd.arg(script_path);
63
64 let pair = pty_system
65 .openpty(PtySize {
66 rows: 24,
67 cols: 80,
68 pixel_width: 0,
69 pixel_height: 0,
70 })
71 .map_err(|e| CarchError::Pty(format!("openpty: {e}")))?;
72
73 let (tx, rx): (ChildKillerSender, ChildKillerReceiver) = oneshot::channel();
74 let pair_slave = pair.slave;
75 let command_handle = std::thread::spawn(move || -> ExitStatus {
76 match pair_slave.spawn_command(cmd) {
77 Ok(mut child) => {
78 let killer = child.clone_killer();
79 if tx.send(Some(killer)).is_err() {
80 log::warn!("child_killer receiver dropped before killer was sent");
81 }
82 child.wait().unwrap_or_else(|e| {
83 log::error!("child.wait failed: {e}");
84 ExitStatus::with_exit_code(1)
85 })
86 }
87 Err(e) => {
88 log::error!("spawn_command failed: {e}");
89 if tx.send(None).is_err() {
90 log::warn!("child_killer receiver dropped before None was sent");
91 }
92 ExitStatus::with_exit_code(1)
93 }
94 }
95 });
96
97 let mut reader = pair
98 .master
99 .try_clone_reader()
100 .map_err(|e| CarchError::Pty(format!("try_clone_reader: {e}")))?;
101
102 let command_buffer: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
103 {
104 let command_buffer = command_buffer.clone();
105 std::thread::spawn(move || {
106 let mut buf = [0u8; 16384];
107 while let Ok(size) = reader.read(&mut buf) {
108 if size == 0 {
109 break;
110 }
111 let mut mutex = match command_buffer.lock() {
112 Ok(g) => g,
113 Err(p) => p.into_inner(),
114 };
115 let data = &buf[0..size];
116 if log_mode {
117 info!("{}", String::from_utf8_lossy(data));
118 }
119 mutex.extend_from_slice(data);
120 }
121 });
122 }
123
124 let writer =
125 pair.master.take_writer().map_err(|e| CarchError::Pty(format!("take_writer: {e}")))?;
126
127 Ok(Self {
128 buffer: command_buffer,
129 command_thread: Some(command_handle),
130 child_killer: Some(rx),
131 pty_master: pair.master,
132 writer,
133 status: None,
134 parser: None,
135 parser_size: (0, 0),
136 pty_size: (0, 0),
137 processed_len: 0,
138 last_max_check: 0,
139 scroll_offset: 0,
140 actual_max: 0,
141 auto_scroll: true,
142 theme,
143 was_finished: false,
144 log_mode,
145 log_path,
146 })
147 }
148
149 pub fn has_new_data(&mut self) -> bool {
150 let now_finished = self.is_finished();
151 if now_finished != self.was_finished {
152 return true;
153 }
154 if self.auto_scroll
155 && let Ok(buf) = self.buffer.try_lock()
156 && buf.len() > self.processed_len
157 {
158 return true;
159 }
160 false
161 }
162
163 pub fn acknowledge_data(&mut self) {
164 self.was_finished = self.is_finished();
165 }
166
167 pub fn handle_key_event(&mut self, key: KeyEvent) -> PopupEvent {
168 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
169 let _shift = key.modifiers.contains(KeyModifiers::SHIFT);
170
171 match key.code {
172 KeyCode::Char('c') if ctrl => {
173 if self.log_mode {
174 info!("Sent Ctrl+C to running script.");
175 }
176 let _ = self.writer.write_all(&[3]);
177 PopupEvent::None
178 }
179 KeyCode::Char('q') | KeyCode::Enter | KeyCode::Esc if self.is_finished() => {
180 if self.log_mode {
181 info!("Closed script output popup.");
182 }
183 PopupEvent::Close
184 }
185 KeyCode::PageUp => {
186 self.scroll_up(PAGE_STEP);
187 PopupEvent::None
188 }
189 KeyCode::PageDown => {
190 self.scroll_down(PAGE_STEP);
191 PopupEvent::None
192 }
193 _ => {
194 self.handle_passthrough_key_event(key);
195 PopupEvent::None
196 }
197 }
198 }
199
200 fn scroll_up(&mut self, n: usize) {
201 if n == 0 {
202 return;
203 }
204 let max = self.max_scrollback();
205 let next = self.scroll_offset.saturating_add(n).min(max);
206 self.scroll_offset = next;
207 self.auto_scroll = next == 0;
208 }
209
210 fn scroll_down(&mut self, n: usize) {
211 if n == 0 {
212 return;
213 }
214 if self.scroll_offset > n {
215 self.scroll_offset -= n;
216 } else {
217 self.scroll_offset = 0;
218 }
219 self.auto_scroll = self.scroll_offset == 0;
220 }
221
222 fn max_scrollback(&self) -> usize {
223 self.actual_max
224 }
225
226 fn is_finished(&self) -> bool {
227 self.command_thread.as_ref().is_none_or(JoinHandle::is_finished)
228 }
229
230 fn sync_parser(&mut self, size: Size) {
231 let (cols, rows) = (size.width, size.height);
232
233 if self.pty_size != (cols, rows) {
234 let _ = self.pty_master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 });
235 self.pty_size = (cols, rows);
236 }
237
238 let parser = self.parser.get_or_insert_with(|| Parser::new(rows, cols, SCROLLBACK_LEN));
239
240 if self.parser_size != (cols, rows) {
241 parser.screen_mut().set_size(rows, cols);
242 self.parser_size = (cols, rows);
243 }
244
245 let new_bytes: Vec<u8> = {
246 let buf = self.buffer.lock().unwrap_or_else(PoisonError::into_inner);
247 if self.processed_len <= buf.len() {
248 buf[self.processed_len..].to_vec()
249 } else {
250 self.processed_len = 0;
251 buf[..].to_vec()
252 }
253 };
254 let got_new = !new_bytes.is_empty();
255 if got_new {
256 parser.process(&new_bytes);
257 self.processed_len += new_bytes.len();
258 }
259
260 if got_new || self.last_max_check == 0 {
261 parser.screen_mut().set_scrollback(usize::MAX);
262 self.actual_max = parser.screen().scrollback();
263 self.last_max_check = self.processed_len;
264 }
265
266 if self.scroll_offset > self.actual_max {
267 self.scroll_offset = self.actual_max;
268 self.auto_scroll = self.actual_max == 0;
269 }
270
271 parser.screen_mut().set_scrollback(self.scroll_offset);
272 }
273
274 fn get_exit_status(&mut self) -> ExitStatus {
275 if self.command_thread.is_some() {
276 let handle = self.command_thread.take().unwrap();
277 let exit_status = handle.join().unwrap_or_else(|_| ExitStatus::with_exit_code(1));
278 self.status = Some(exit_status.clone());
279 exit_status
280 } else {
281 self.status.clone().unwrap_or_else(|| ExitStatus::with_exit_code(1))
282 }
283 }
284
285 pub fn kill_child(&mut self) {
286 if !self.is_finished()
287 && let Some(killer_rx) = self.child_killer.take()
288 && let Ok(Some(mut killer)) = killer_rx.recv()
289 {
290 let _ = killer.kill();
291 }
292 }
293
294 fn handle_passthrough_key_event(&mut self, key: KeyEvent) {
295 let input_bytes = match key.code {
296 KeyCode::Char(ch) => ch.to_string().into_bytes(),
297 KeyCode::Enter => vec![b'\r'],
298 KeyCode::Backspace => vec![0x7f],
299 KeyCode::Left => vec![27, 91, 68],
300 KeyCode::Right => vec![27, 91, 67],
301 KeyCode::Up => vec![27, 91, 65],
302 KeyCode::Down => vec![27, 91, 66],
303 KeyCode::Tab => vec![9],
304 KeyCode::Home => vec![27, 91, 72],
305 KeyCode::End => vec![27, 91, 70],
306 KeyCode::BackTab => vec![27, 91, 90],
307 KeyCode::Delete => vec![27, 91, 51, 126],
308 KeyCode::Insert => vec![27, 91, 50, 126],
309 KeyCode::Esc => vec![27],
310 _ => return,
311 };
312 let _ = self.writer.write_all(&input_bytes);
313 }
314}
315
316impl Drop for RunScriptPopup {
317 fn drop(&mut self) {
318 if !self.is_finished()
319 && let Some(rx) = self.child_killer.take()
320 && let Ok(Some(mut killer)) = rx.recv()
321 {
322 let _ = killer.kill();
323 }
324 }
325}
326
327impl Widget for &mut RunScriptPopup {
328 fn render(self, area: Rect, buf: &mut Buffer) {
329 let running = !self.is_finished();
330
331 let block = if running {
332 Block::bordered()
333 .border_set(border::ROUNDED)
334 .border_style(Style::default().fg(self.theme.primary))
335 .title_style(Style::default().fg(self.theme.primary).reversed())
336 } else {
337 let success = self.get_exit_status().success();
338 let (title_text, style_color) = if success {
339 (
340 Line::styled(
341 "Success! Press <Enter> to close",
342 Style::default().fg(self.theme.success).reversed(),
343 ),
344 self.theme.success,
345 )
346 } else {
347 (
348 Line::styled(
349 "Failed! Press <Enter> to close",
350 Style::default().fg(self.theme.error).reversed(),
351 ),
352 self.theme.error,
353 )
354 };
355
356 let mut block = Block::bordered()
357 .border_set(border::ROUNDED)
358 .border_style(Style::default().fg(style_color))
359 .title_top(title_text.centered());
360
361 if let Some(ref path) = self.log_path {
362 block = block.title_bottom(
363 Line::styled(
364 format!(" Log saved: {path} "),
365 Style::default().fg(self.theme.warning),
366 )
367 .centered(),
368 );
369 }
370
371 block
372 };
373
374 let inner_area = block.inner(area);
375 self.sync_parser(inner_area.as_size());
376
377 Clear.render(area, buf);
378 block.render(area, buf);
379
380 if let Some(parser) = self.parser.as_ref() {
381 let pseudo_term = PseudoTerminal::new(parser.screen());
382 pseudo_term.render(inner_area, buf);
383 }
384 }
385}