Skip to main content

trail/terminal/
mod.rs

1//! The terminal panel: interactive shells in a panel under the file list.
2//!
3//! Behaviour is specified in `docs/terminal_panel.md`; this module is that
4//! spec's state machine. [`TerminalPanel`] lives on [`crate::app::state::AppState`] and owns the
5//! [`session::Session`]s; [`handle_key`] runs before Trail's own keymap and
6//! decides whether a keystroke belongs to the panel; `:term` commands arrive
7//! through [`run_command`]. Drawing is `ui::terminal_panel`'s job.
8//!
9//! Trail and the shells are deliberately independent: a shell starts in the
10//! folder Trail shows, and from then on neither moves the other.
11
12pub mod busy;
13mod input;
14pub mod keys;
15pub mod profile;
16pub mod session;
17
18pub use input::{allow_quit, handle_key, run_command};
19
20use std::path::Path;
21
22use tokio::sync::mpsc;
23
24use crate::config::TerminalConfig;
25use crate::workers::WorkerMsg;
26use session::Session;
27
28/// Smallest panel worth drawing: a border, and three rows of shell inside it.
29pub const MIN_PANEL_ROWS: u16 = 5;
30/// Fewest rows the file list keeps before the panel takes the whole screen.
31pub const MIN_LIST_ROWS: u16 = 5;
32
33/// The action names `[keymap.terminal]` accepts.
34pub const TERMINAL_ACTIONS: &[&str] = &[
35    "toggle",
36    "focus",
37    "next_shell",
38    "prev_shell",
39    "scroll_up",
40    "scroll_down",
41];
42
43/// When Trail asks before ending shells: `[terminal] confirm_quit` and
44/// `confirm_close`.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ConfirmPolicy {
47    /// Ask only when a shell is running a command.
48    WhenBusy,
49    /// Ask every time there is a shell to end.
50    Always,
51    /// Never ask.
52    Never,
53}
54
55/// Explanation attached to a rejected confirmation setting.
56pub const CONFIRM_REASON: &str = "must be when_busy, always or never";
57
58impl ConfirmPolicy {
59    /// Parses a config value, case-insensitively.
60    #[must_use]
61    pub fn parse(value: &str) -> Option<Self> {
62        match value.trim().to_ascii_lowercase().as_str() {
63            "when_busy" => Some(Self::WhenBusy),
64            "always" => Some(Self::Always),
65            "never" => Some(Self::Never),
66            _ => None,
67        }
68    }
69}
70
71/// A `:term` command.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum TermCommand {
74    /// `:term` — the same as the toggle key.
75    Toggle,
76    /// `:term new [profile]` — a new shell in Trail's current folder.
77    New(Option<String>),
78    /// `:term close` — end the current shell.
79    Close,
80    /// `:term <n>` — switch to shell number `n` (1-based).
81    Select(usize),
82    /// `:term max` — maximize or restore the panel.
83    Max,
84}
85
86/// A question the panel is waiting on, answered with `y`/Enter or `n`/Esc.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum Confirm {
89    /// Quitting Trail would end the shells. `cancel` is Ctrl+C's quit, which
90    /// skips the cd-on-exit handoff.
91    Quit {
92        /// Whether the quit was a cancel (`Ctrl+C`) rather than `q`.
93        cancel: bool,
94        /// The prompt, already worded for how many shells are busy.
95        prompt: String,
96    },
97    /// `:term close` would end a shell.
98    Close {
99        /// The prompt naming the shell.
100        prompt: String,
101    },
102}
103
104impl Confirm {
105    /// The question as the status bar shows it.
106    #[must_use]
107    pub fn prompt(&self) -> &str {
108        match self {
109            Self::Quit { prompt, .. } | Self::Close { prompt } => prompt,
110        }
111    }
112}
113
114/// What [`handle_key`] did with a keystroke.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum KeyOutcome {
117    /// Not the panel's key: Trail's keymap should handle it.
118    NotMine,
119    /// The panel used it.
120    Handled,
121    /// The user confirmed quitting. `cancel` as in [`Confirm::Quit`].
122    Quit {
123        /// Whether to quit without the cd-on-exit handoff.
124        cancel: bool,
125    },
126}
127
128/// The panel's state: its shells, and how it is shown.
129#[derive(Debug, Default)]
130pub struct TerminalPanel {
131    sessions: Vec<Session>,
132    active: usize,
133    visible: bool,
134    shell_focused: bool,
135    maximized: bool,
136    next_id: u64,
137    pending: Option<Confirm>,
138    output_pending: bool,
139    notify: Option<mpsc::Sender<WorkerMsg>>,
140}
141
142impl TerminalPanel {
143    /// Connects the panel to the worker channel its sessions report on.
144    /// Until this is called — as in tests — opening a shell is an error.
145    pub fn attach(&mut self, notify: mpsc::Sender<WorkerMsg>) {
146        self.notify = Some(notify);
147    }
148
149    /// Whether the panel is on screen.
150    #[must_use]
151    pub fn is_visible(&self) -> bool {
152        self.visible
153    }
154
155    /// Whether keystrokes go to the shell rather than to Trail.
156    #[must_use]
157    pub fn shell_focused(&self) -> bool {
158        self.visible && self.shell_focused
159    }
160
161    /// Whether the panel takes the whole screen above the status bar.
162    #[must_use]
163    pub fn is_maximized(&self) -> bool {
164        self.maximized
165    }
166
167    /// The open shells, in tab order.
168    #[must_use]
169    pub fn sessions(&self) -> &[Session] {
170        &self.sessions
171    }
172
173    /// Index of the shell on screen.
174    #[must_use]
175    pub fn active(&self) -> usize {
176        self.active
177    }
178
179    /// The question waiting for an answer, if any.
180    #[must_use]
181    pub fn pending(&self) -> Option<&Confirm> {
182        self.pending.as_ref()
183    }
184
185    /// Resizes every shell to the panel's inner size. Called by the renderer,
186    /// which is where the size is known.
187    pub fn resize_all(&mut self, size: (u16, u16)) {
188        for session in &mut self.sessions {
189            session.resize(size);
190        }
191    }
192
193    /// Records that a shell produced output. The event loop draws it at the
194    /// next frame rather than immediately, so a flood is drawn at a capped rate.
195    ///
196    /// A hidden panel draws nothing, so its output is not worth a frame. Its
197    /// readers stay quiet until the next frame drawn for any other reason
198    /// resets them, which also means a hidden shell costs one message per
199    /// frame at most, however much it prints.
200    pub fn note_output(&mut self) {
201        if self.visible {
202            self.output_pending = true;
203        }
204    }
205
206    /// Whether output is waiting to be drawn.
207    #[must_use]
208    pub fn output_pending(&self) -> bool {
209        self.output_pending
210    }
211
212    /// Called after a frame is drawn: what was pending is on screen, and every
213    /// reader may announce new output again.
214    pub fn frame_drawn(&mut self) {
215        self.output_pending = false;
216        for session in &self.sessions {
217            session.output_drawn();
218        }
219    }
220
221    /// Removes the shell `id` after it exited. Returns whether anything changed.
222    pub fn on_exit(&mut self, id: u64) -> bool {
223        let Some(index) = self.sessions.iter().position(|s| s.id() == id) else {
224            return false;
225        };
226        self.sessions.remove(index);
227        self.after_removal(index);
228        true
229    }
230
231    fn after_removal(&mut self, index: usize) {
232        if self.sessions.is_empty() {
233            self.visible = false;
234            self.shell_focused = false;
235            self.maximized = false;
236            self.active = 0;
237            if matches!(self.pending, Some(Confirm::Close { .. })) {
238                self.pending = None;
239            }
240            return;
241        }
242        if index < self.active || self.active >= self.sessions.len() {
243            self.active = self.active.saturating_sub(1);
244        }
245    }
246
247    /// Starts a new shell from `profile_name` (or the default) in `cwd`, makes
248    /// it the active one, and shows and focuses the panel.
249    ///
250    /// # Errors
251    ///
252    /// Returns a message for the status bar when the profile is unknown, the
253    /// panel is not attached, or the program cannot be started.
254    pub fn new_shell(
255        &mut self,
256        config: &TerminalConfig,
257        profile_name: Option<&str>,
258        cwd: &Path,
259    ) -> Result<(), String> {
260        let profile = profile::resolve(config, profile_name)?;
261        let notify = self
262            .notify
263            .clone()
264            .ok_or_else(|| "the terminal panel is not available".to_owned())?;
265        self.next_id += 1;
266        let size = initial_size(config.height, self.maximized);
267        let session = Session::spawn(
268            self.next_id,
269            &profile.name,
270            &profile.argv,
271            cwd,
272            size,
273            notify,
274        )?;
275        self.sessions.push(session);
276        self.active = self.sessions.len() - 1;
277        self.visible = true;
278        self.shell_focused = true;
279        Ok(())
280    }
281
282    /// The toggle key: shows the panel (starting a shell if there is none) and
283    /// focuses it, or hides it and returns focus to the file list.
284    ///
285    /// # Errors
286    ///
287    /// As [`Self::new_shell`], when opening has to start a shell.
288    pub fn toggle(&mut self, config: &TerminalConfig, cwd: &Path) -> Result<(), String> {
289        if self.visible {
290            self.visible = false;
291            self.shell_focused = false;
292            self.maximized = false;
293            return Ok(());
294        }
295        self.show(config, cwd)
296    }
297
298    /// The focus key: moves the keyboard to the other side, opening the panel
299    /// first if it is hidden.
300    ///
301    /// # Errors
302    ///
303    /// As [`Self::new_shell`], when opening has to start a shell.
304    pub fn switch_focus(&mut self, config: &TerminalConfig, cwd: &Path) -> Result<(), String> {
305        if !self.visible {
306            return self.show(config, cwd);
307        }
308        self.shell_focused = !self.shell_focused;
309        Ok(())
310    }
311
312    fn show(&mut self, config: &TerminalConfig, cwd: &Path) -> Result<(), String> {
313        if self.sessions.is_empty() {
314            return self.new_shell(config, None, cwd);
315        }
316        self.visible = true;
317        self.shell_focused = true;
318        Ok(())
319    }
320
321    /// Switches to the shell `delta` tabs away, wrapping.
322    pub fn cycle(&mut self, delta: isize) {
323        let len = self.sessions.len();
324        if len > 1 {
325            let len = len as isize;
326            self.active = (self.active as isize + delta).rem_euclid(len) as usize;
327        }
328    }
329
330    /// Switches to shell number `n`, counted from 1 as the tabs are labelled.
331    ///
332    /// # Errors
333    ///
334    /// Returns a message when there is no shell `n`.
335    pub fn select(&mut self, n: usize) -> Result<(), String> {
336        if n == 0 || n > self.sessions.len() {
337            return Err(format!(
338                "no shell {n} (there {})",
339                match self.sessions.len() {
340                    0 => "are none".to_owned(),
341                    1 => "is 1".to_owned(),
342                    k => format!("are {k}"),
343                }
344            ));
345        }
346        self.active = n - 1;
347        self.visible = true;
348        Ok(())
349    }
350
351    /// Ends the active shell now, without asking.
352    pub fn close_active(&mut self) {
353        if self.active < self.sessions.len() {
354            let index = self.active;
355            self.sessions.remove(index);
356            self.after_removal(index);
357        }
358    }
359
360    /// Maximizes the panel, or restores it.
361    pub fn toggle_maximized(&mut self) {
362        if self.visible {
363            self.maximized = !self.maximized;
364        }
365    }
366
367    /// Whether quitting needs a confirmation under `policy`, and if so the
368    /// question to ask. Reads the process table when the policy is
369    /// `when_busy`, which costs tens of milliseconds — fine on a quit.
370    #[must_use]
371    pub fn quit_confirmation(&self, policy: ConfirmPolicy, cancel: bool) -> Option<Confirm> {
372        if self.sessions.is_empty() {
373            return None;
374        }
375        let busy = match policy {
376            ConfirmPolicy::Never => return None,
377            ConfirmPolicy::Always | ConfirmPolicy::WhenBusy => self.busy_count(),
378        };
379        let prompt = match (policy, busy) {
380            (ConfirmPolicy::WhenBusy, 0) => return None,
381            (_, 0) => format!(" Quit and close {}? [y/N] ", shells(self.sessions.len())),
382            (_, 1) => " 1 shell is still running a command. Quit anyway? [y/N] ".to_owned(),
383            (_, n) => format!(" {n} shells are still running a command. Quit anyway? [y/N] "),
384        };
385        Some(Confirm::Quit { cancel, prompt })
386    }
387
388    fn busy_count(&self) -> usize {
389        let pids: Vec<Option<u32>> = self.sessions.iter().map(Session::pid).collect();
390        busy::busy(&pids).into_iter().filter(|b| *b).count()
391    }
392
393    /// `:term close`: closes the active shell, or asks first under `policy`.
394    pub fn request_close(&mut self, policy: ConfirmPolicy) {
395        let Some(session) = self.sessions.get(self.active) else {
396            return;
397        };
398        let name = format!("{}:{}", self.active + 1, session.label());
399        let prompt = match policy {
400            ConfirmPolicy::Never => None,
401            ConfirmPolicy::Always | ConfirmPolicy::WhenBusy => {
402                let running = busy::busy(&[session.pid()])
403                    .first()
404                    .copied()
405                    .unwrap_or(true);
406                match (policy, running) {
407                    (_, true) => Some(format!(
408                        " Shell {name} is running a command. Close it? [y/N] "
409                    )),
410                    (ConfirmPolicy::Always, false) => Some(format!(" Close shell {name}? [y/N] ")),
411                    _ => None,
412                }
413            }
414        };
415        match prompt {
416            Some(prompt) => self.pending = Some(Confirm::Close { prompt }),
417            None => self.close_active(),
418        }
419    }
420
421    /// Puts a quit question up for the user to answer.
422    pub fn ask(&mut self, confirm: Confirm) {
423        self.pending = Some(confirm);
424    }
425}
426
427fn shells(n: usize) -> String {
428    if n == 1 {
429        "1 shell".to_owned()
430    } else {
431        format!("{n} shells")
432    }
433}
434
435/// The panel's height for a main area of `main_rows`, as `ui` lays it out.
436///
437/// `percent` is `[terminal] height`. A panel that would leave the file list
438/// fewer than [`MIN_LIST_ROWS`] takes the whole area instead, as the spec
439/// says for a very short screen.
440#[must_use]
441pub fn panel_rows(main_rows: u16, percent: u16, maximized: bool) -> u16 {
442    if maximized {
443        return main_rows;
444    }
445    let wanted = (u32::from(main_rows) * u32::from(percent) / 100) as u16;
446    let rows = wanted.max(MIN_PANEL_ROWS);
447    if main_rows.saturating_sub(rows) < MIN_LIST_ROWS {
448        main_rows
449    } else {
450        rows
451    }
452}
453
454/// The inner size (rows, cols) a new shell starts at, from the real terminal's
455/// size. The renderer corrects it on the first frame if the guess is off.
456fn initial_size(percent: u16, maximized: bool) -> (u16, u16) {
457    let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
458    let main_rows = rows.saturating_sub(1);
459    let panel = panel_rows(main_rows, percent, maximized);
460    (panel.saturating_sub(2), cols.saturating_sub(2))
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn confirm_policies_parse() {
469        assert_eq!(
470            ConfirmPolicy::parse("when_busy"),
471            Some(ConfirmPolicy::WhenBusy)
472        );
473        assert_eq!(
474            ConfirmPolicy::parse(" Always "),
475            Some(ConfirmPolicy::Always)
476        );
477        assert_eq!(ConfirmPolicy::parse("never"), Some(ConfirmPolicy::Never));
478        assert_eq!(ConfirmPolicy::parse("sometimes"), None);
479    }
480
481    #[test]
482    fn the_panel_takes_its_share_and_falls_back_to_the_whole_area() {
483        assert_eq!(panel_rows(40, 35, false), 14);
484        assert_eq!(panel_rows(40, 35, true), 40);
485        // Never smaller than a usable panel.
486        assert_eq!(panel_rows(20, 10, false), MIN_PANEL_ROWS);
487        // Too short for both: the panel gets everything.
488        assert_eq!(panel_rows(9, 35, false), 9);
489    }
490
491    #[test]
492    fn an_empty_panel_never_asks_and_never_opens_without_a_channel() {
493        let panel = TerminalPanel::default();
494        assert_eq!(panel.quit_confirmation(ConfirmPolicy::Always, false), None);
495
496        let dir = tempfile::tempdir().unwrap();
497        let config = crate::config::load(None).unwrap().terminal;
498        let mut panel = TerminalPanel::default();
499        let err = panel.toggle(&config, dir.path()).unwrap_err();
500        assert!(err.contains("not available"), "{err}");
501        assert!(!panel.is_visible(), "a failed open leaves the panel hidden");
502    }
503}