1use std::io::Write;
12
13use crate::layout::{Node, Pane, Split};
14
15const 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
36pub 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
61pub 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}