Skip to main content

ctx_tui/
shellrun.rs

1//! Run pane commands through the user's interactive shell.
2//!
3//! Multiplexers exec a pane's command directly, bypassing the shell, so
4//! prompt-hook environment loaders (direnv, mise, ...) never run for the
5//! pane. via_shell defers each command to a launcher script instead: the
6//! user's shell starts interactively with the script as stdin, sources its
7//! rc files and fires its prompt hooks, and only then reads the script's
8//! single line, which execs the command with stdin re-pointed at the
9//! pane's tty.
10
11use std::io::Write;
12
13use crate::layout::{Node, Pane, Split};
14
15// XXX Assumes $SHELL is a POSIX-family shell that runs its prompt hooks
16// before the first read from a non-tty stdin (holds for zsh and bash;
17// fish untested, non-POSIX shells would break).
18const LAUNCHER_HEAD: &str = r#"#!/bin/sh
19shell="${SHELL:-/bin/sh}"
20case "${shell##*/}" in
21    zsh)
22        # zsh's line editor reads the tty directly, ignoring a non-tty
23        # stdin; +o zle makes zsh read the heredoc.
24        set -- -i +o zle
25        ;;
26    *)
27        set -- -i
28        ;;
29esac
30# Hand the pane tty down on fd 9 rather than reopening /dev/tty: macOS
31# kqueue cannot watch the /dev/tty alias, leaving kqueue-polling TUIs
32# (anything on Node) deaf to input.
33exec "$shell" "$@" 9<&0 <<'CTX_PANE_COMMAND'
34"#;
35
36/// POSIX shell quoting with Python shlex.quote's exact output shape.
37pub fn quote(s: &str) -> String {
38    if s.is_empty() {
39        return "''".to_string();
40    }
41    let safe = |c: char| c.is_ascii_alphanumeric() || "_@%+=:,./-".contains(c);
42    if s.chars().all(safe) {
43        return s.to_string();
44    }
45    format!("'{}'", s.replace('\'', r#"'"'"'"#))
46}
47
48fn launcher(command: &str) -> std::io::Result<String> {
49    let mut file = tempfile::Builder::new()
50        .prefix("ctx-pane-")
51        .suffix(".sh")
52        .disable_cleanup(true)
53        .tempfile()?;
54    write!(
55        file,
56        "{LAUNCHER_HEAD}exec {command} <&9 9<&-\nCTX_PANE_COMMAND\n"
57    )?;
58    Ok(format!("sh {}", quote(&file.path().to_string_lossy())))
59}
60
61/// Defer each command pane to a launcher run by the user's shell.
62pub fn via_shell(node: &Node) -> std::io::Result<Node> {
63    Ok(match node {
64        Node::Pane(pane) => match &pane.command {
65            None => node.clone(),
66            Some(command) => Node::Pane(Pane {
67                command: Some(launcher(command)?),
68                focus: pane.focus,
69                ..Pane::default()
70            }),
71        },
72        Node::Split(split) => Node::Split(Split {
73            direction: split.direction,
74            panes: split
75                .panes
76                .iter()
77                .map(via_shell)
78                .collect::<Result<_, _>>()?,
79        }),
80    })
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::layout::SplitDirection;
87
88    fn script(node: &Node) -> String {
89        let Node::Pane(pane) = node else {
90            panic!("expected a pane")
91        };
92        let command = pane.command.as_deref().expect("pane has a command");
93        let argv = shlex::split(command).expect("command parses");
94        assert_eq!(argv[0], "sh");
95        std::fs::read_to_string(&argv[1]).expect("launcher script exists")
96    }
97
98    fn command_pane(command: &str) -> Node {
99        Node::Pane(Pane {
100            command: Some(command.to_string()),
101            ..Pane::default()
102        })
103    }
104
105    #[test]
106    fn leaves_plain_shell_panes_alone() {
107        let pane = Node::Pane(Pane {
108            focus: true,
109            ..Pane::default()
110        });
111        assert_eq!(via_shell(&pane).unwrap(), pane);
112    }
113
114    #[test]
115    fn defers_the_command_to_a_launcher_script() {
116        let pane = Node::Pane(Pane {
117            command: Some("nvim -R file.txt".to_string()),
118            focus: true,
119            ..Pane::default()
120        });
121
122        let wrapped = via_shell(&pane).unwrap();
123
124        let Node::Pane(ref inner) = wrapped else {
125            panic!("expected a pane")
126        };
127        assert!(inner.focus);
128        let script = script(&wrapped);
129        assert!(script.contains("exec nvim -R file.txt <&9 9<&-"));
130        assert!(script.contains(r#""$shell" "$@""#));
131    }
132
133    #[test]
134    fn starts_the_shell_interactively() {
135        assert!(script(&via_shell(&command_pane("htop")).unwrap()).contains("set -- -i"));
136    }
137
138    #[test]
139    fn recurses_into_splits() {
140        let split = Node::Split(Split {
141            direction: SplitDirection::Row,
142            panes: vec![command_pane("nvim"), Node::Pane(Pane::default())],
143        });
144
145        let wrapped = via_shell(&split).unwrap();
146
147        let Node::Split(split) = wrapped else {
148            panic!("expected a split")
149        };
150        let Node::Pane(first) = &split.panes[0] else {
151            panic!("expected a pane")
152        };
153        assert!(first.command.as_deref().unwrap().starts_with("sh "));
154        assert_eq!(split.panes[1], Node::Pane(Pane::default()));
155    }
156
157    #[test]
158    fn writes_a_fresh_script_per_pane() {
159        let first = via_shell(&command_pane("nvim")).unwrap();
160        let second = via_shell(&command_pane("nvim")).unwrap();
161
162        assert_ne!(first, second);
163    }
164
165    #[test]
166    fn quote_matches_python_shlex() {
167        assert_eq!(quote(""), "''");
168        assert_eq!(quote("plain-word_1.txt"), "plain-word_1.txt");
169        assert_eq!(quote("two words"), "'two words'");
170        assert_eq!(quote("it's"), r#"'it'"'"'s'"#);
171    }
172}