1use std::collections::HashMap;
2use std::path::Path;
3use std::process::Stdio;
4
5use super::CmdError;
6use crate::contexts::Context;
7use crate::git::new_command;
8use crate::layout::{Node, Pane, SplitDirection, resolve_layout};
9use crate::multiplexer::{Multiplexer, MultiplexerError, env_truthy};
10use crate::shellrun::via_shell;
11
12fn session_name(ctx: &Context) -> String {
13 let raw = format!("{}--{}", ctx.repo, ctx.name);
14 raw.replace(['.', ':'], "-")
16}
17
18fn tmux(args: &[&str]) -> Result<String, CmdError> {
19 let argv = || {
20 ["tmux"]
21 .iter()
22 .copied()
23 .chain(args.iter().copied())
24 .map(str::to_string)
25 .collect()
26 };
27 let output = new_command("tmux")
28 .args(args)
29 .stdout(Stdio::piped())
30 .stderr(Stdio::piped())
31 .output()
32 .map_err(|err| CmdError {
33 argv: argv(),
34 stderr: err.to_string(),
35 })?;
36 if !output.status.success() {
37 return Err(CmdError {
38 argv: argv(),
39 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
40 });
41 }
42 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
43}
44
45fn first_pane(node: &Node) -> &Pane {
47 match node {
48 Node::Pane(pane) => pane,
49 Node::Split(split) => first_pane(&split.panes[0]),
50 }
51}
52
53fn build<'a>(
55 node: &'a Node,
56 pane_id: &str,
57 cwd: &Path,
58) -> Result<Vec<(String, &'a Pane)>, CmdError> {
59 let split = match node {
60 Node::Pane(pane) => return Ok(vec![(pane_id.to_string(), pane)]),
61 Node::Split(split) => split,
62 };
63 let flag = match split.direction {
64 SplitDirection::Row => "-h",
65 SplitDirection::Column => "-v",
66 };
67 let cwd_str = cwd.to_string_lossy();
68 let mut regions = vec![pane_id.to_string()];
69 for child in &split.panes[1..] {
70 let target = regions.last().expect("regions starts non-empty").clone();
71 let mut args = vec![
72 "split-window",
73 flag,
74 "-t",
75 &target,
76 "-c",
77 &cwd_str,
78 "-P",
79 "-F",
80 "#{pane_id}",
81 ];
82 let command = &first_pane(child).command;
83 if let Some(command) = command {
84 args.push(command);
85 }
86 regions.push(tmux(&args)?);
87 }
88 let mut leaves = Vec::new();
89 for (child, region) in split.panes.iter().zip(®ions) {
90 leaves.extend(build(child, region, cwd)?);
91 }
92 Ok(leaves)
93}
94
95fn create_session(session: &str, cwd: &Path, layout: &Node) -> Result<(), MultiplexerError> {
96 let cwd_str = cwd.to_string_lossy();
100 let mut args = vec![
101 "new-session",
102 "-d",
103 "-s",
104 session,
105 "-c",
106 &cwd_str,
107 "-P",
108 "-F",
109 "#{pane_id}",
110 ];
111 let command = &first_pane(layout).command;
112 if let Some(command) = command {
113 args.push(command);
114 }
115 let built = tmux(&args).and_then(|first| build(layout, &first, cwd));
116 let leaves = match built {
117 Ok(leaves) => leaves,
118 Err(err) => {
119 let _ = tmux(&["kill-session", "-t", &format!("={session}")]);
120 if err.stderr.contains("command too long") {
121 return Err(MultiplexerError(
122 "a pane command exceeds tmux's ~16KB limit".to_string(),
123 ));
124 }
125 return Err(err.into());
126 }
127 };
128 let focused = leaves
129 .iter()
130 .find(|(_, pane)| pane.focus)
131 .or_else(|| leaves.first())
132 .map(|(pane_id, _)| pane_id.clone())
133 .expect("a layout always has at least one pane");
134 tmux(&["select-pane", "-t", &focused])?;
135 Ok(())
136}
137
138pub struct TmuxMultiplexer {
139 layout: Node,
140}
141
142impl TmuxMultiplexer {
143 pub fn new(layout: Node) -> TmuxMultiplexer {
144 TmuxMultiplexer { layout }
145 }
146}
147
148impl Multiplexer for TmuxMultiplexer {
149 fn can_open_in_place(&self) -> bool {
150 env_truthy("TMUX")
152 }
153
154 fn exists(&self, ctx: &Context) -> bool {
155 new_command("tmux")
156 .args(["has-session", "-t", &format!("={}", session_name(ctx))])
157 .stdout(Stdio::null())
158 .stderr(Stdio::null())
159 .status()
160 .map(|status| status.success())
161 .unwrap_or(false)
162 }
163
164 fn is_current(&self, ctx: &Context) -> bool {
165 if !env_truthy("TMUX") {
166 return false;
167 }
168 tmux(&["display-message", "-p", "#S"])
169 .map(|session| session == session_name(ctx))
170 .unwrap_or(false)
171 }
172
173 fn create(
174 &self,
175 ctx: &Context,
176 values: Option<&HashMap<String, String>>,
177 ) -> Result<(), MultiplexerError> {
178 if !self.exists(ctx) {
179 let layout = via_shell(&resolve_layout(&self.layout, values))
180 .map_err(|err| MultiplexerError(err.to_string()))?;
181 create_session(&session_name(ctx), &ctx.path, &layout)?;
182 }
183 Ok(())
184 }
185
186 fn open(
187 &self,
188 ctx: &Context,
189 values: Option<&HashMap<String, String>>,
190 ) -> Result<(), MultiplexerError> {
191 let session = session_name(ctx);
192 self.create(ctx, values)?;
193 if env_truthy("TMUX") {
194 tmux(&["switch-client", "-t", &format!("={session}")])?;
195 Ok(())
196 } else {
197 use std::os::unix::process::CommandExt;
198
199 let err = new_command("tmux")
200 .args(["attach-session", "-t", &format!("={session}")])
201 .exec();
202 Err(MultiplexerError(format!("could not exec tmux: {err}")))
203 }
204 }
205
206 fn kill(&self, ctx: &Context) -> Result<(), MultiplexerError> {
207 tmux(&["kill-session", "-t", &format!("={}", session_name(ctx))])?;
208 Ok(())
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use std::path::PathBuf;
215
216 use super::*;
217 use crate::testutil::{push_env, test_env};
218
219 fn ctx(repo: &str, name: &str) -> Context {
220 Context {
221 repo: repo.to_string(),
222 name: name.to_string(),
223 path: PathBuf::from("/w"),
224 }
225 }
226
227 fn stub_tmux(
230 env: &crate::testutil::TestEnv,
231 extra: &str,
232 ) -> (PathBuf, crate::testutil::EnvGuard) {
233 let log = env.root().join("tmux.log");
234 let script = format!(
235 "{{ printf '%s\\037' \"$@\"; printf '\\n'; }} >> {log}\n{extra}\necho \"%$(grep -c '' {log})\"",
236 log = log.display()
237 );
238 let guard = env.fake_cli("tmux", &script);
239 (log, guard)
240 }
241
242 fn calls(log: &Path) -> Vec<Vec<String>> {
243 let Ok(text) = std::fs::read_to_string(log) else {
244 return Vec::new();
245 };
246 text.lines()
247 .map(|line| {
248 line.split('\x1f')
249 .filter(|part| !part.is_empty())
250 .map(str::to_string)
251 .collect()
252 })
253 .collect()
254 }
255
256 #[test]
257 fn session_name_replaces_forbidden_characters() {
258 assert_eq!(session_name(&ctx("my.repo", "a:b")), "my-repo--a-b");
259 }
260
261 #[test]
262 fn is_current_is_false_outside_tmux() {
263 let _tmux_unset = push_env("TMUX", "");
264
265 assert!(!TmuxMultiplexer::new(Node::Pane(Pane::default())).is_current(&ctx("repo", "a")));
266 }
267
268 #[test]
269 fn is_current_compares_the_attached_session() {
270 let env = test_env();
271 let _guard = env.fake_cli("tmux", "echo 'repo--a'");
272 let _tmux = push_env("TMUX", "/tmp/tmux-1/default,1,0");
273 let mux = TmuxMultiplexer::new(Node::Pane(Pane::default()));
274
275 assert!(mux.is_current(&ctx("repo", "a")));
276 assert!(!mux.is_current(&ctx("repo", "b")));
277 }
278
279 #[test]
280 fn open_resolves_builtin_panes_on_session_creation() {
281 let env = test_env();
282 let (log, _guard) = stub_tmux(&env, "[ \"$1\" = has-session ] && exit 1");
284 let _tmux = push_env("TMUX", "/tmp/tmux-1/default,1,0");
285 let layout = Node::Pane(Pane {
286 builtin: Some("claude".to_string()),
287 ..Pane::default()
288 });
289 let mux = TmuxMultiplexer::new(layout);
290 let values = HashMap::from([("prompt".to_string(), "hi".to_string())]);
291
292 mux.open(&ctx("repo", "a"), Some(&values)).unwrap();
293
294 let new_sessions: Vec<_> = calls(&log)
295 .into_iter()
296 .filter(|call| call[0] == "new-session")
297 .collect();
298 assert_eq!(new_sessions.len(), 1);
299 let launcher = shlex::split(new_sessions[0].last().unwrap()).unwrap();
300 assert_eq!(launcher[0], "sh");
301 let script = std::fs::read_to_string(&launcher[1]).unwrap();
302 assert!(script.contains("exec sh -c 'ctx builtin claude trust; exec claude hi' <&9 9<&-"));
303 }
304
305 #[test]
306 fn split_panes_start_their_own_commands() {
307 let env = test_env();
308 let (log, _guard) = stub_tmux(&env, "");
309 let layout = Node::Split(crate::layout::Split {
310 direction: SplitDirection::Row,
311 panes: vec![
312 Node::Pane(Pane {
313 command: Some("nvim".to_string()),
314 ..Pane::default()
315 }),
316 Node::Pane(Pane::default()),
317 Node::Pane(Pane {
318 command: Some("htop".to_string()),
319 ..Pane::default()
320 }),
321 ],
322 });
323
324 create_session("s", Path::new("/w"), &layout).unwrap();
325
326 let calls = calls(&log);
327 let new_sessions: Vec<_> = calls.iter().filter(|c| c[0] == "new-session").collect();
328 assert_eq!(new_sessions.len(), 1);
329 assert_eq!(new_sessions[0].last().unwrap(), "nvim");
330 let splits: Vec<_> = calls.iter().filter(|c| c[0] == "split-window").collect();
331 assert_eq!(splits.len(), 2);
332 assert_eq!(splits[0].last().unwrap(), "#{pane_id}");
333 assert_eq!(splits[1].last().unwrap(), "htop");
334 }
335
336 #[test]
337 fn reports_an_over_long_pane_command() {
338 let env = test_env();
339 let log = env.root().join("tmux.log");
340 let script = format!(
341 "{{ printf '%s\\037' \"$@\"; printf '\\n'; }} >> {log}\n\
342 [ \"$1\" = new-session ] && {{ echo 'command too long' >&2; exit 1; }}\n\
343 exit 0",
344 log = log.display()
345 );
346 let _guard = env.fake_cli("tmux", &script);
347 let layout = Node::Pane(Pane {
348 command: Some(format!("claude {}", "x".repeat(20_000))),
349 ..Pane::default()
350 });
351
352 let err = create_session("s", Path::new("/w"), &layout).expect_err("must fail");
353
354 assert!(err.to_string().contains("16KB"));
355 let kills: Vec<_> = calls(&log)
356 .into_iter()
357 .filter(|call| call[0] == "kill-session")
358 .collect();
359 assert_eq!(kills, vec![vec!["kill-session", "-t", "=s"]]);
360 }
361
362 #[test]
363 fn create_does_not_attach() {
364 let env = test_env();
365 let (log, _guard) = stub_tmux(&env, "[ \"$1\" = has-session ] && exit 1");
366 let _tmux = push_env("TMUX", "/tmp/tmux-1/default,1,0");
367 let mux = TmuxMultiplexer::new(Node::Pane(Pane::default()));
368
369 mux.create(&ctx("repo", "a"), Some(&HashMap::new()))
370 .unwrap();
371
372 let calls = calls(&log);
373 assert!(calls.iter().any(|call| call[0] == "new-session"));
374 assert!(
375 !calls
376 .iter()
377 .any(|call| call[0] == "switch-client" || call[0] == "attach-session")
378 );
379 }
380}