Skip to main content

carch_core/ui/popups/
run_script.rs

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