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    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}