1use std::collections::{BTreeMap, HashMap};
2use std::io::Write;
3use std::path::Path;
4use std::process::Stdio;
5
6use sha2::{Digest, Sha256};
7
8use crate::contexts::Context;
9use crate::git::new_command;
10use crate::layout::{Node, SplitDirection, resolve_layout};
11use crate::multiplexer::{Multiplexer, MultiplexerError, env_truthy, env_var};
12use crate::shellrun::via_shell;
13
14const SOCKET_PATH_MAX: usize = 103;
19
20fn session_name_budget() -> Option<usize> {
22 if !cfg!(target_os = "macos") {
23 return None;
24 }
25 let uid = unsafe { libc::getuid() };
27 let sock_dir = std::env::temp_dir()
28 .join(format!("zellij-{uid}"))
29 .join("contract_version_1");
30 Some(SOCKET_PATH_MAX.saturating_sub(sock_dir.to_string_lossy().len() + 1))
31}
32
33fn session_name(ctx: &Context) -> String {
34 let name = format!("{}--{}", ctx.repo, ctx.name).replace(['.', ':'], "-");
35 session_name_within(name, session_name_budget())
36}
37
38fn session_name_within(name: String, budget: Option<usize>) -> String {
39 let Some(budget) = budget else {
40 return name;
41 };
42 if name.chars().count() <= budget {
43 return name;
44 }
45 let digest: String = Sha256::digest(name.as_bytes())
49 .iter()
50 .map(|byte| format!("{byte:02x}"))
51 .collect();
52 let keep = std::cmp::max(budget.saturating_sub(7), 1);
53 let kept: String = name.chars().take(keep).collect();
54 format!("{kept}-{}", &digest[..6])
55}
56
57fn kdl_string(value: &str) -> String {
58 let escaped = value
59 .replace('\\', "\\\\")
60 .replace('"', "\\\"")
61 .replace('\n', "\\n");
62 format!("\"{escaped}\"")
63}
64
65fn render_node(node: &Node, cwd: &Path, indent: usize) -> Result<String, MultiplexerError> {
66 let pad = " ".repeat(indent);
67 let split = match node {
68 Node::Pane(pane) => {
69 let argv = match &pane.command {
70 Some(command) => shlex::split(command).ok_or_else(|| {
71 MultiplexerError(format!("pane command has unbalanced quoting: {command}"))
72 })?,
73 None => Vec::new(),
74 };
75 let mut line = format!("{pad}pane");
76 if let Some(program) = argv.first() {
77 line += &format!(" command={}", kdl_string(program));
78 }
79 line += &format!(" cwd={}", kdl_string(&cwd.to_string_lossy()));
80 if pane.focus {
81 line += " focus=true";
82 }
83 if argv.len() > 1 {
84 let args = argv[1..]
85 .iter()
86 .map(|arg| kdl_string(arg))
87 .collect::<Vec<_>>()
88 .join(" ");
89 line += &format!(" {{\n{pad} args {args}\n{pad}}}");
90 }
91 return Ok(line);
92 }
93 Node::Split(split) => split,
94 };
95 let direction = match split.direction {
98 SplitDirection::Row => "vertical",
99 SplitDirection::Column => "horizontal",
100 };
101 let children = split
102 .panes
103 .iter()
104 .map(|pane| render_node(pane, cwd, indent + 1))
105 .collect::<Result<Vec<_>, _>>()?
106 .join("\n");
107 Ok(format!(
108 "{pad}pane split_direction=\"{direction}\" {{\n{children}\n{pad}}}"
109 ))
110}
111
112fn render_layout(layout: &Node, cwd: &Path) -> Result<String, MultiplexerError> {
113 Ok(format!(
114 "layout {{\n default_tab_template {{\n pane size=1 borderless=true {{\n plugin location=\"zellij:tab-bar\"\n }}\n children\n pane size=2 borderless=true {{\n plugin location=\"zellij:status-bar\"\n }}\n }}\n tab {{\n{}\n }}\n}}\n",
115 render_node(layout, cwd, 2)?
116 ))
117}
118
119fn env_without_zellij() -> Vec<(String, String)> {
125 let mut env: BTreeMap<String, String> = std::env::vars().collect();
126 #[cfg(test)]
127 crate::testutil::overlay_env(&mut env);
128 env.retain(|key, _| !key.starts_with("ZELLIJ"));
129 env.into_iter().collect()
130}
131
132fn run_zellij(mut cmd: std::process::Command, message: String) -> Result<(), MultiplexerError> {
135 let output = cmd
136 .stdout(Stdio::piped())
137 .stderr(Stdio::piped())
138 .output()
139 .map_err(|err| MultiplexerError(err.to_string()))?;
140 if output.status.success() {
141 return Ok(());
142 }
143 let stderr = String::from_utf8_lossy(&output.stderr);
144 let stdout = String::from_utf8_lossy(&output.stdout);
145 let detail = if stderr.trim().is_empty() {
146 stdout.trim().to_string()
147 } else {
148 stderr.trim().to_string()
149 };
150 Err(MultiplexerError(if detail.is_empty() {
151 message
152 } else {
153 format!("{message}: {detail}")
154 }))
155}
156
157pub struct ZellijMultiplexer {
158 layout: Node,
159}
160
161impl ZellijMultiplexer {
162 pub fn new(layout: Node) -> ZellijMultiplexer {
163 ZellijMultiplexer { layout }
164 }
165
166 fn write_layout_file(
167 &self,
168 ctx: &Context,
169 values: Option<&HashMap<String, String>>,
170 ) -> Result<String, MultiplexerError> {
171 let to_mux_err = |err: std::io::Error| MultiplexerError(err.to_string());
172 let mut file = tempfile::Builder::new()
173 .prefix("ctx-")
174 .suffix(".kdl")
175 .disable_cleanup(true)
176 .tempfile()
177 .map_err(to_mux_err)?;
178 let layout = via_shell(&resolve_layout(&self.layout, values)).map_err(to_mux_err)?;
179 file.write_all(render_layout(&layout, &ctx.path)?.as_bytes())
180 .map_err(to_mux_err)?;
181 Ok(file.path().to_string_lossy().into_owned())
182 }
183}
184
185impl Multiplexer for ZellijMultiplexer {
186 fn can_open_in_place(&self) -> bool {
187 env_truthy("ZELLIJ")
189 }
190
191 fn exists(&self, ctx: &Context) -> bool {
192 let output = new_command("zellij")
193 .args(["list-sessions", "--short"])
194 .stdout(Stdio::piped())
195 .stderr(Stdio::null())
196 .output();
197 let Ok(output) = output else {
199 return false;
200 };
201 if !output.status.success() {
202 return false;
203 }
204 let session = session_name(ctx);
205 String::from_utf8_lossy(&output.stdout)
206 .lines()
207 .any(|line| line == session)
208 }
209
210 fn is_current(&self, ctx: &Context) -> bool {
211 env_var("ZELLIJ_SESSION_NAME").as_deref() == Some(session_name(ctx).as_str())
212 }
213
214 fn create(
215 &self,
216 ctx: &Context,
217 values: Option<&HashMap<String, String>>,
218 ) -> Result<(), MultiplexerError> {
219 if self.exists(ctx) {
220 return Ok(());
221 }
222 let session = session_name(ctx);
223 let layout_file = self.write_layout_file(ctx, values)?;
224 let mut cmd = std::process::Command::new("zellij");
225 cmd.env_clear().envs(env_without_zellij());
226 cmd.args([
227 "--layout",
228 &layout_file,
229 "attach",
230 "--create-background",
231 &session,
232 ]);
233 run_zellij(cmd, format!("zellij could not create '{session}'"))
234 }
235
236 fn open(
237 &self,
238 ctx: &Context,
239 values: Option<&HashMap<String, String>>,
240 ) -> Result<(), MultiplexerError> {
241 use std::os::unix::process::CommandExt;
242
243 let session = session_name(ctx);
244 let exists = self.exists(ctx);
245 if env_truthy("ZELLIJ") {
246 let mut args = vec![
250 "action".to_string(),
251 "switch-session".to_string(),
252 session.clone(),
253 ];
254 if !exists {
255 args.push("--layout".to_string());
256 args.push(self.write_layout_file(ctx, values)?);
257 }
258 let mut cmd = new_command("zellij");
259 cmd.args(&args);
260 return run_zellij(cmd, format!("zellij could not switch to '{session}'"));
261 }
262 let err = if exists {
263 new_command("zellij").args(["attach", &session]).exec()
264 } else {
265 let layout_file = self.write_layout_file(ctx, values)?;
266 new_command("zellij")
267 .args([
268 "--session",
269 &session,
270 "--new-session-with-layout",
271 &layout_file,
272 ])
273 .exec()
274 };
275 Err(MultiplexerError(format!("could not exec zellij: {err}")))
276 }
277
278 fn kill(&self, ctx: &Context) -> Result<(), MultiplexerError> {
279 let session = session_name(ctx);
280 let output = new_command("zellij")
281 .args(["delete-session", "--force", &session])
282 .stdout(Stdio::null())
283 .stderr(Stdio::piped())
284 .output()
285 .map_err(|err| MultiplexerError(err.to_string()))?;
286 if !output.status.success() {
287 return Err(super::CmdError {
288 argv: vec![
289 "zellij".to_string(),
290 "delete-session".to_string(),
291 "--force".to_string(),
292 session,
293 ],
294 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
295 }
296 .into());
297 }
298 Ok(())
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use std::path::PathBuf;
305
306 use super::*;
307 use crate::layout::{Pane, Split};
308 use crate::testutil::{push_env, test_env};
309
310 fn ctx(repo: &str, name: &str) -> Context {
311 Context {
312 repo: repo.to_string(),
313 name: name.to_string(),
314 path: PathBuf::from("/w"),
315 }
316 }
317
318 fn pane(command: &str) -> Node {
319 Node::Pane(Pane {
320 command: Some(command.to_string()),
321 ..Pane::default()
322 })
323 }
324
325 #[test]
326 fn session_name_replaces_forbidden_characters() {
327 assert_eq!(
328 session_name_within("my.repo--a:b".replace(['.', ':'], "-"), None),
329 "my-repo--a-b"
330 );
331 }
332
333 #[test]
334 fn session_name_within_budget_is_unchanged() {
335 assert_eq!(
336 session_name_within("repo--short".to_string(), Some(20)),
337 "repo--short"
338 );
339 }
340
341 #[test]
342 fn session_name_over_budget_is_shortened() {
343 let name = session_name_within("repo--a-very-long-context-name".to_string(), Some(20));
344
345 assert_eq!(name.len(), 20);
346 assert!(name.starts_with("repo--a-very-"));
347 }
348
349 #[test]
350 fn session_name_survives_a_zero_budget() {
351 let name = session_name_within("repo--name".to_string(), Some(0));
354
355 assert!(
356 name.len() <= 8,
357 "budget 0 must yield a minimal name: {name}"
358 );
359 }
360
361 #[test]
362 fn session_name_truncates_multibyte_names_by_character() {
363 let name = session_name_within("repo--überlanger-kontext-name".to_string(), Some(20));
364
365 assert_eq!(name.chars().count(), 20);
366 assert!(name.starts_with("repo--über"));
367 }
368
369 #[test]
370 fn shortened_session_names_stay_unique() {
371 let first = session_name_within("repo--a-very-long-context-name".to_string(), Some(20));
372 let second = session_name_within("repo--a-very-long-context-nam2".to_string(), Some(20));
373
374 assert_ne!(first, second);
375 }
376
377 #[test]
378 fn is_current_matches_the_session_env() {
379 let mux = ZellijMultiplexer::new(Node::Pane(Pane::default()));
380 let context = ctx("repo", "a");
381
382 {
383 let _env = push_env("ZELLIJ_SESSION_NAME", &session_name(&context));
384 assert!(mux.is_current(&context));
385 }
386 {
387 let _env = push_env("ZELLIJ_SESSION_NAME", "elsewhere");
388 assert!(!mux.is_current(&context));
389 }
390 }
391
392 #[test]
393 fn layout_puts_pane_in_cwd() {
394 let out = render_layout(&Node::Pane(Pane::default()), Path::new("/w")).unwrap();
395
396 assert!(out.contains("pane cwd=\"/w\""));
397 }
398
399 #[test]
400 fn layout_splits_command_into_args() {
401 let out = render_layout(&pane("nvim -R file.txt"), Path::new("/w")).unwrap();
402
403 assert!(out.contains("pane command=\"nvim\" cwd=\"/w\""));
404 assert!(out.contains("args \"-R\" \"file.txt\""));
405 }
406
407 #[test]
408 fn create_makes_a_background_session() {
409 let env = test_env();
410 let args_log = env.root().join("zellij-args.log");
411 let env_log = env.root().join("zellij-env.log");
412 let script = format!(
415 "{{ printf '%s\\037' \"$@\"; printf '\\n'; }} >> {args}\n\
416 case \"$*\" in *--create-background*) printenv | grep '^ZELLIJ' >> {env} || true;; esac\n\
417 exit 0",
418 args = args_log.display(),
419 env = env_log.display()
420 );
421 let _guard = env.fake_cli("zellij", &script);
422 let _zellij = push_env("ZELLIJ", "0");
423 let _session = push_env("ZELLIJ_SESSION_NAME", "elsewhere");
424 let mux = ZellijMultiplexer::new(Node::Pane(Pane::default()));
425
426 mux.create(&ctx("repo", "a"), Some(&HashMap::new()))
427 .unwrap();
428
429 let text = std::fs::read_to_string(&args_log).unwrap();
430 let create_line = text
431 .lines()
432 .find(|line| line.contains("--create-background"))
433 .expect("create invocation logged");
434 let args: Vec<&str> = create_line
435 .split('\x1f')
436 .filter(|s| !s.is_empty())
437 .collect();
438 assert_eq!(args[0], "--layout");
439 assert_eq!(args[2..], ["attach", "--create-background", "repo--a"]);
440 let leaked = std::fs::read_to_string(&env_log).unwrap_or_default();
443 assert_eq!(leaked.trim(), "");
444 }
445
446 #[test]
447 fn layout_file_resolves_builtin_panes() {
448 let mux = ZellijMultiplexer::new(Node::Pane(Pane {
449 builtin: Some("claude".to_string()),
450 ..Pane::default()
451 }));
452 let values = HashMap::from([("prompt".to_string(), "explore x".to_string())]);
453
454 let layout_file = mux
455 .write_layout_file(&ctx("repo", "a"), Some(&values))
456 .unwrap();
457
458 let content = std::fs::read_to_string(&layout_file).unwrap();
459 assert!(content.contains("command=\"sh\""));
460 let args_start = content.find("args \"").expect("args in layout") + 6;
461 let script_path =
462 &content[args_start..content[args_start..].find('"').unwrap() + args_start];
463 let script = std::fs::read_to_string(script_path).unwrap();
464 assert!(script.contains("ctx builtin claude trust; exec claude '\"'\"'explore x'\"'\"'"));
465 }
466
467 #[test]
468 fn layout_escapes_kdl_strings() {
469 let out = render_layout(&pane("claude 'say \"hi\"'"), Path::new("/w")).unwrap();
470
471 assert!(out.contains("args \"say \\\"hi\\\"\""));
472 }
473
474 #[test]
475 fn layout_marks_focus() {
476 let node = Node::Pane(Pane {
477 command: Some("nvim".to_string()),
478 focus: true,
479 ..Pane::default()
480 });
481
482 assert!(
483 render_layout(&node, Path::new("/w"))
484 .unwrap()
485 .contains("focus=true")
486 );
487 }
488
489 #[test]
490 fn layout_maps_row_to_vertical_split() {
491 let node = Node::Split(Split {
492 direction: SplitDirection::Row,
493 panes: vec![Node::Pane(Pane::default()), Node::Pane(Pane::default())],
494 });
495
496 let out = render_layout(&node, Path::new("/w")).unwrap();
497
498 assert!(out.contains("split_direction=\"vertical\""));
499 }
500
501 #[test]
502 fn layout_maps_column_to_horizontal_split() {
503 let node = Node::Split(Split {
504 direction: SplitDirection::Column,
505 panes: vec![Node::Pane(Pane::default()), Node::Pane(Pane::default())],
506 });
507
508 let out = render_layout(&node, Path::new("/w")).unwrap();
509
510 assert!(out.contains("split_direction=\"horizontal\""));
511 }
512}