1use std::path::Path;
2use std::sync::Arc;
3
4use anyhow::{Context, Result, bail};
5
6use crate::process::{CommandOutput, CommandRunner, default_runner};
7use crate::templates::{PaneLayout, PanePosition, SessionPlan};
8use crate::util;
9
10#[derive(Debug, Clone, Eq, PartialEq)]
11pub struct SessionSnapshot {
12 pub session_name: String,
13 pub active_window: String,
14 pub active_pane: usize,
15 pub active_path: std::path::PathBuf,
16 pub windows: Vec<WindowSnapshot>,
17}
18
19#[derive(Debug, Clone, Eq, PartialEq)]
20pub struct WindowSnapshot {
21 pub name: String,
22 pub synchronize: bool,
23 pub active: bool,
24 pub panes: Vec<PaneSnapshot>,
25}
26
27#[derive(Debug, Clone, Eq, PartialEq)]
28pub struct PaneSnapshot {
29 pub cwd: std::path::PathBuf,
30 pub active: bool,
31 pub layout: Option<PaneLayout>,
32}
33
34#[derive(Debug, Clone, Eq, PartialEq)]
35struct WindowRecord {
36 id: String,
37 name: String,
38 active: bool,
39}
40
41#[derive(Debug, Clone, Eq, PartialEq)]
42struct PaneRecord {
43 index: usize,
44 cwd: std::path::PathBuf,
45 active: bool,
46 left: i32,
47 top: i32,
48 width: i32,
49 height: i32,
50}
51
52#[derive(Clone)]
53pub struct Tmux {
54 runner: Arc<dyn CommandRunner>,
55}
56
57impl Default for Tmux {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63impl Tmux {
64 pub fn new() -> Self {
65 Self {
66 runner: default_runner(),
67 }
68 }
69
70 pub fn with_runner(runner: Arc<dyn CommandRunner>) -> Self {
71 Self { runner }
72 }
73
74 pub fn list_sessions(&self) -> Result<Vec<String>> {
75 let output = self.runner.run_capture(
76 "tmux",
77 &[
78 "list-sessions".to_owned(),
79 "-F".to_owned(),
80 "#{session_name}".to_owned(),
81 ],
82 );
83
84 match output {
85 Ok(output) if output.status.success => {
86 let stdout =
87 String::from_utf8(output.stdout).context("tmux output was not utf-8")?;
88 Ok(stdout
89 .lines()
90 .map(str::trim)
91 .filter(|line| !line.is_empty())
92 .map(ToOwned::to_owned)
93 .collect())
94 }
95 Ok(output) => {
96 let stderr = String::from_utf8_lossy(&output.stderr);
97
98 if is_empty_session_state(stderr.as_ref()) {
99 Ok(Vec::new())
100 } else {
101 bail!("tmux list-sessions failed: {}", stderr.trim())
102 }
103 }
104 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
105 bail!("tmux is not installed or not on PATH")
106 }
107 Err(error) => Err(error).context("failed to execute tmux list-sessions"),
108 }
109 }
110
111 pub fn current_session(&self) -> Result<Option<String>> {
112 if !util::inside_tmux() {
113 return Ok(None);
114 }
115
116 let output = self
117 .runner
118 .run_capture(
119 "tmux",
120 &[
121 "display-message".to_owned(),
122 "-p".to_owned(),
123 "#{session_name}".to_owned(),
124 ],
125 )
126 .context("failed to execute tmux display-message")?;
127
128 if !output.status.success {
129 let stderr = String::from_utf8_lossy(&output.stderr);
130 bail!("tmux display-message failed: {}", stderr.trim());
131 }
132
133 let stdout = String::from_utf8(output.stdout).context("tmux output was not utf-8")?;
134 let session = stdout.trim();
135 if session.is_empty() {
136 Ok(None)
137 } else {
138 Ok(Some(session.to_owned()))
139 }
140 }
141
142 pub fn has_session(&self, session: &str) -> Result<bool> {
143 let output = self
144 .runner
145 .run_capture(
146 "tmux",
147 &[
148 "has-session".to_owned(),
149 "-t".to_owned(),
150 session.to_owned(),
151 ],
152 )
153 .context("failed to execute tmux has-session")?;
154
155 Ok(output.status.success)
156 }
157
158 pub fn ensure_session_exists(&self, session: &str) -> Result<()> {
159 if self.has_session(session)? {
160 Ok(())
161 } else {
162 bail!("tmux session not found: {session}")
163 }
164 }
165
166 pub fn create_session(&self, session: &str, directory: &Path) -> Result<()> {
167 let directory = util::path_to_string(directory)?;
168 let output = self
169 .run_tmux_capture([
170 "new-session",
171 "-d",
172 "-s",
173 session,
174 "-c",
175 &directory,
176 "-n",
177 "main",
178 ])
179 .context("failed to execute tmux new-session")?;
180
181 if output.status.success {
182 Ok(())
183 } else {
184 let stderr = String::from_utf8_lossy(&output.stderr);
185 bail!("tmux new-session failed: {}", stderr.trim())
186 }
187 }
188
189 pub fn create_session_from_plan(&self, plan: &SessionPlan) -> Result<()> {
190 let first_window = plan
191 .windows
192 .first()
193 .context("session plan must contain at least one window")?;
194
195 self.create_session_with_window(
196 &plan.session_name,
197 &first_window.name,
198 initial_pane_cwd(first_window),
199 )?;
200 self.configure_panes(&plan.session_name, &first_window.name, first_window)?;
201
202 let mut previous_window = first_window.name.as_str();
203 for window in plan.windows.iter().skip(1) {
204 self.new_window_after(
205 &plan.session_name,
206 previous_window,
207 &window.name,
208 initial_pane_cwd(window),
209 )?;
210 self.configure_panes(&plan.session_name, &window.name, window)?;
211 previous_window = &window.name;
212 }
213
214 self.select_window(&plan.session_name, &plan.startup_window)?;
215 self.select_pane_by_offset(&plan.session_name, &plan.startup_window, plan.startup_pane)?;
216 Ok(())
217 }
218
219 pub fn switch_or_attach(&self, session: &str) -> Result<()> {
220 if util::inside_tmux() {
221 self.run_tmux(["switch-client", "-t", session])
222 .context("failed to execute tmux switch-client")
223 } else {
224 let args = vec![
225 "attach-session".to_owned(),
226 "-t".to_owned(),
227 session.to_owned(),
228 ];
229
230 let status = self
231 .runner
232 .run_inherit("tmux", &args)
233 .context("failed to execute tmux attach-session")?;
234
235 if status.success {
236 Ok(())
237 } else {
238 bail!("tmux attach-session failed with status {:?}", status.code)
239 }
240 }
241 }
242
243 pub fn kill_session(&self, session: &str) -> Result<()> {
244 self.run_tmux(["kill-session", "-t", session])
245 .context("failed to execute tmux kill-session")
246 }
247
248 pub fn capture_session(&self, session: &str) -> Result<SessionSnapshot> {
249 self.ensure_session_exists(session)?;
250
251 let windows = self.list_windows(session)?;
252 let active_window_name = windows
253 .iter()
254 .find(|window| window.active)
255 .or_else(|| windows.first())
256 .context("tmux session did not contain any windows")?;
257 let active_window_name = active_window_name.name.clone();
258
259 let mut captured_windows = Vec::with_capacity(windows.len());
260 let mut active_pane = None;
261 let mut active_path = None;
262
263 for window in windows {
264 let synchronize = self.window_synchronize(&window.id)?;
265 let panes = self.list_pane_records(&window.id)?;
266 let panes = infer_pane_layouts(panes);
267
268 if window.active {
269 let active = panes
270 .iter()
271 .enumerate()
272 .find(|(_, pane)| pane.active)
273 .or_else(|| panes.first().map(|pane| (0, pane)))
274 .context("active tmux window did not contain any panes")?;
275 active_pane = Some(active.0);
276 active_path = Some(active.1.cwd.clone());
277 }
278
279 captured_windows.push(WindowSnapshot {
280 name: window.name,
281 synchronize,
282 active: window.active,
283 panes,
284 });
285 }
286
287 let active_path =
288 active_path.context("could not determine the active pane path for the tmux session")?;
289
290 Ok(SessionSnapshot {
291 session_name: session.to_owned(),
292 active_window: active_window_name,
293 active_pane: active_pane.unwrap_or(0),
294 active_path,
295 windows: captured_windows,
296 })
297 }
298
299 fn create_session_with_window(
300 &self,
301 session: &str,
302 window: &str,
303 directory: &Path,
304 ) -> Result<()> {
305 let directory = util::path_to_string(directory)?;
306 self.run_tmux([
307 "new-session",
308 "-d",
309 "-s",
310 session,
311 "-c",
312 &directory,
313 "-n",
314 window,
315 ])
316 .context("failed to execute tmux new-session")
317 }
318
319 fn new_window_after(
320 &self,
321 session: &str,
322 after_window: &str,
323 window: &str,
324 directory: &Path,
325 ) -> Result<()> {
326 let directory = util::path_to_string(directory)?;
327 let target = format!("{session}:{after_window}");
328 self.run_tmux([
329 "new-window",
330 "-a",
331 "-t",
332 &target,
333 "-n",
334 window,
335 "-c",
336 &directory,
337 ])
338 .context("failed to execute tmux new-window")
339 }
340
341 fn send_keys_to_target(&self, target: &str, command: &str) -> Result<()> {
342 self.run_tmux(["send-keys", "-t", target, command, "C-m"])
343 .context("failed to execute tmux send-keys")
344 }
345
346 fn split_window(&self, target: &str, layout: &PaneLayout, directory: &Path) -> Result<String> {
347 let directory = util::path_to_string(directory)?;
348 let mut args = vec![
349 "split-window".to_owned(),
350 "-t".to_owned(),
351 target.to_owned(),
352 "-P".to_owned(),
353 "-F".to_owned(),
354 "#{pane_id}".to_owned(),
355 ];
356
357 match layout.position {
358 PanePosition::Right | PanePosition::Left => args.push("-h".to_owned()),
359 PanePosition::Bottom | PanePosition::Top => args.push("-v".to_owned()),
360 }
361
362 match layout.position {
363 PanePosition::Left | PanePosition::Top => args.push("-b".to_owned()),
364 PanePosition::Right | PanePosition::Bottom => {}
365 }
366
367 if let Some(size) = &layout.size {
368 args.push("-l".to_owned());
369 args.push(size.clone());
370 }
371
372 args.push("-c".to_owned());
373 args.push(directory);
374
375 let output = self
376 .runner
377 .run_capture("tmux", &args)
378 .context("failed to execute tmux split-window")?;
379
380 if !output.status.success {
381 let stderr = String::from_utf8_lossy(&output.stderr);
382 bail!("tmux split-window failed: {}", stderr.trim());
383 }
384
385 let pane_id =
386 String::from_utf8(output.stdout).context("tmux split-window output was not utf-8")?;
387 Ok(pane_id.trim().to_owned())
388 }
389
390 fn select_layout(&self, target: &str, layout: &str) -> Result<()> {
391 self.run_tmux(["select-layout", "-t", target, layout])
392 .context("failed to execute tmux select-layout")
393 }
394
395 fn select_window(&self, session: &str, window: &str) -> Result<()> {
396 let target = format!("{session}:{window}");
397 self.run_tmux(["select-window", "-t", &target])
398 .context("failed to execute tmux select-window")
399 }
400
401 fn select_pane_target(&self, target: &str) -> Result<()> {
402 self.run_tmux(["select-pane", "-t", target])
403 .context("failed to execute tmux select-pane")
404 }
405
406 fn set_synchronize_panes(&self, session: &str, window: &str, enabled: bool) -> Result<()> {
407 let target = format!("{session}:{window}");
408 let value = if enabled { "on" } else { "off" };
409 self.run_tmux([
410 "set-window-option",
411 "-t",
412 &target,
413 "synchronize-panes",
414 value,
415 ])
416 .context("failed to execute tmux set-window-option")
417 }
418
419 fn zoom_pane(&self, target: &str) -> Result<()> {
420 self.run_tmux(["resize-pane", "-Z", "-t", target])
421 .context("failed to execute tmux resize-pane -Z")
422 }
423
424 fn configure_panes(
425 &self,
426 session: &str,
427 window: &str,
428 plan: &crate::templates::WindowPlan,
429 ) -> Result<()> {
430 let target = format!("{session}:{window}");
431 let pane_ids = self.list_panes(&target)?;
432 let first_pane_target = pane_ids
433 .first()
434 .cloned()
435 .context("tmux window did not contain an initial pane")?;
436 let mut zoom_target = if plan.panes.is_empty() {
437 None
438 } else if plan.panes[0].zoom {
439 Some(first_pane_target.clone())
440 } else {
441 None
442 };
443
444 if plan.panes.is_empty() {
445 if let Some(pre_command) = &plan.pre_command {
446 self.send_keys_to_target(&first_pane_target, pre_command)?;
447 }
448 if let Some(command) = &plan.command {
449 self.send_keys_to_target(&first_pane_target, command)?;
450 }
451 if plan.synchronize {
452 self.set_synchronize_panes(session, window, true)?;
453 }
454 if let Some(target) = zoom_target.as_deref() {
455 self.zoom_pane(target)?;
456 }
457 return Ok(());
458 }
459
460 if let Some(pre_command) = &plan.pre_command {
461 self.send_keys_to_target(&first_pane_target, pre_command)
462 .context("failed to execute tmux send-keys for first pane pre_command")?;
463 }
464 if let Some(command) = &plan.panes[0].command {
465 self.send_keys_to_target(&first_pane_target, command)
466 .context("failed to execute tmux send-keys for first pane")?;
467 }
468
469 for (pane_index, pane) in plan.panes.iter().enumerate().skip(1) {
470 let layout = pane.layout.as_ref().ok_or_else(|| {
471 anyhow::anyhow!(
472 "pane {} in window \"{}\" is missing a layout",
473 pane_index,
474 window
475 )
476 })?;
477 let pane_target = self.split_window(&target, layout, &pane.cwd)?;
478 if let Some(pre_command) = &plan.pre_command {
479 self.send_keys_to_target(&pane_target, pre_command)
480 .context("failed to execute tmux send-keys for split pane pre_command")?;
481 }
482 if let Some(command) = &pane.command {
483 self.send_keys_to_target(&pane_target, command)
484 .context("failed to execute tmux send-keys for split pane")?;
485 }
486 if pane.zoom {
487 zoom_target = Some(pane_target.clone());
488 }
489 }
490
491 if let Some(layout) = &plan.layout {
492 self.select_layout(&target, layout)?;
493 }
494
495 if plan.synchronize {
496 self.set_synchronize_panes(session, window, true)?;
497 }
498
499 if let Some(target) = zoom_target.as_deref() {
500 self.zoom_pane(target)?;
501 }
502
503 Ok(())
504 }
505
506 fn list_panes(&self, target: &str) -> Result<Vec<String>> {
507 let output = self
508 .run_tmux_capture(["list-panes", "-t", target, "-F", "#{pane_id}"])
509 .context("failed to execute tmux list-panes")?;
510
511 if !output.status.success {
512 let stderr = String::from_utf8_lossy(&output.stderr);
513 bail!("tmux list-panes failed: {}", stderr.trim());
514 }
515
516 let stdout =
517 String::from_utf8(output.stdout).context("tmux list-panes output was not utf-8")?;
518 Ok(stdout
519 .lines()
520 .map(str::trim)
521 .filter(|line| !line.is_empty())
522 .map(ToOwned::to_owned)
523 .collect())
524 }
525
526 fn select_pane_by_offset(&self, session: &str, window: &str, pane_offset: usize) -> Result<()> {
527 let target = format!("{session}:{window}");
528 let panes = self.list_panes(&target)?;
529 let pane = panes.get(pane_offset).with_context(|| {
530 format!(
531 "startup pane offset {} was not found in window {}",
532 pane_offset, target
533 )
534 })?;
535 self.select_pane_target(pane)
536 }
537
538 fn run_tmux<const N: usize>(&self, args: [&str; N]) -> Result<()> {
539 let output = self.run_tmux_capture(args)?;
540
541 if output.status.success {
542 Ok(())
543 } else {
544 let stderr = String::from_utf8_lossy(&output.stderr);
545 bail!("{}", stderr.trim())
546 }
547 }
548
549 fn run_tmux_capture<const N: usize>(&self, args: [&str; N]) -> Result<CommandOutput> {
550 let args = args.into_iter().map(ToOwned::to_owned).collect::<Vec<_>>();
551 self.runner.run_capture("tmux", &args).map_err(Into::into)
552 }
553
554 fn list_windows(&self, session: &str) -> Result<Vec<WindowRecord>> {
555 let output = self
556 .run_tmux_capture([
557 "list-windows",
558 "-t",
559 session,
560 "-F",
561 "#{window_id}\t#{window_name}\t#{window_active}",
562 ])
563 .context("failed to execute tmux list-windows")?;
564
565 if !output.status.success {
566 let stderr = String::from_utf8_lossy(&output.stderr);
567 bail!("tmux list-windows failed: {}", stderr.trim());
568 }
569
570 let stdout =
571 String::from_utf8(output.stdout).context("tmux list-windows output was not utf-8")?;
572 stdout
573 .lines()
574 .filter(|line| !line.trim().is_empty())
575 .map(parse_window_record)
576 .collect()
577 }
578
579 fn window_synchronize(&self, window_id: &str) -> Result<bool> {
580 let output = self
581 .run_tmux_capture([
582 "show-window-options",
583 "-t",
584 window_id,
585 "-v",
586 "synchronize-panes",
587 ])
588 .context("failed to execute tmux show-window-options")?;
589
590 if !output.status.success {
591 let stderr = String::from_utf8_lossy(&output.stderr);
592 bail!("tmux show-window-options failed: {}", stderr.trim());
593 }
594
595 let stdout = String::from_utf8(output.stdout)
596 .context("tmux show-window-options output was not utf-8")?;
597 Ok(stdout.trim() == "on")
598 }
599
600 fn list_pane_records(&self, window_id: &str) -> Result<Vec<PaneRecord>> {
601 let output = self
602 .run_tmux_capture([
603 "list-panes",
604 "-t",
605 window_id,
606 "-F",
607 "#{pane_index}\t#{pane_current_path}\t#{pane_active}\t#{pane_left}\t#{pane_top}\t#{pane_width}\t#{pane_height}",
608 ])
609 .context("failed to execute tmux list-panes")?;
610
611 if !output.status.success {
612 let stderr = String::from_utf8_lossy(&output.stderr);
613 bail!("tmux list-panes failed: {}", stderr.trim());
614 }
615
616 let stdout =
617 String::from_utf8(output.stdout).context("tmux list-panes output was not utf-8")?;
618 let mut panes = stdout
619 .lines()
620 .filter(|line| !line.trim().is_empty())
621 .map(parse_pane_record)
622 .collect::<Result<Vec<_>>>()?;
623 panes.sort_by_key(|pane| pane.index);
624 Ok(panes)
625 }
626}
627
628fn is_empty_session_state(stderr: &str) -> bool {
629 let stderr = stderr.trim();
630
631 stderr.contains("no server running")
632 || stderr.contains("failed to connect to server")
633 || (stderr.contains("error connecting to") && stderr.contains("No such file or directory"))
634}
635
636fn parse_window_record(line: &str) -> Result<WindowRecord> {
637 let mut parts = line.splitn(3, '\t');
638 let id = parts.next().context("missing tmux window id")?.to_owned();
639 let name = parts.next().context("missing tmux window name")?.to_owned();
640 let active = match parts.next().context("missing tmux window active flag")? {
641 "1" => true,
642 "0" => false,
643 other => bail!("invalid tmux window active flag: {other}"),
644 };
645
646 Ok(WindowRecord { id, name, active })
647}
648
649fn parse_pane_record(line: &str) -> Result<PaneRecord> {
650 let mut parts = line.splitn(7, '\t');
651 let index = parts
652 .next()
653 .context("missing tmux pane index")?
654 .parse()
655 .context("tmux pane index was not a number")?;
656 let cwd = std::path::PathBuf::from(parts.next().context("missing tmux pane cwd")?);
657 let active = match parts.next().context("missing tmux pane active flag")? {
658 "1" => true,
659 "0" => false,
660 other => bail!("invalid tmux pane active flag: {other}"),
661 };
662 let left = parts
663 .next()
664 .context("missing tmux pane left coordinate")?
665 .parse()
666 .context("tmux pane left was not a number")?;
667 let top = parts
668 .next()
669 .context("missing tmux pane top coordinate")?
670 .parse()
671 .context("tmux pane top was not a number")?;
672 let width = parts
673 .next()
674 .context("missing tmux pane width")?
675 .parse()
676 .context("tmux pane width was not a number")?;
677 let height = parts
678 .next()
679 .context("missing tmux pane height")?
680 .parse()
681 .context("tmux pane height was not a number")?;
682
683 Ok(PaneRecord {
684 index,
685 cwd,
686 active,
687 left,
688 top,
689 width,
690 height,
691 })
692}
693
694fn infer_pane_layouts(panes: Vec<PaneRecord>) -> Vec<PaneSnapshot> {
695 let mut inferred = Vec::with_capacity(panes.len());
696
697 for pane in panes {
698 let layout = if inferred.is_empty() {
699 None
700 } else {
701 Some(PaneLayout {
702 position: infer_pane_position(&pane, &inferred),
703 size: None,
704 })
705 };
706
707 inferred.push(PaneSnapshot {
708 cwd: pane.cwd,
709 active: pane.active,
710 layout,
711 });
712 }
713
714 inferred
715}
716
717fn infer_pane_position(pane: &PaneRecord, previous: &[PaneSnapshot]) -> PanePosition {
718 let _ = previous;
719 if pane.left > 0 && pane.top == 0 {
720 PanePosition::Right
721 } else if pane.top > 0 && pane.left == 0 {
722 PanePosition::Bottom
723 } else if pane.left > 0 {
724 PanePosition::Right
725 } else if pane.top > 0 {
726 PanePosition::Bottom
727 } else {
728 PanePosition::Right
729 }
730}
731
732fn initial_pane_cwd(window: &crate::templates::WindowPlan) -> &Path {
733 window
734 .panes
735 .first()
736 .map(|pane| pane.cwd.as_path())
737 .unwrap_or(window.cwd.as_path())
738}
739
740#[cfg(test)]
741mod tests {
742 use std::sync::Arc;
743 use std::sync::Mutex;
744
745 use crate::process::{CommandOutput, CommandStatus, FakeCommandRunner, IoMode};
746 use crate::templates::{PaneLayout, PanePlan, PanePosition, SessionPlan, WindowPlan};
747
748 use super::Tmux;
749
750 static TMUX_ENV_LOCK: Mutex<()> = Mutex::new(());
751
752 #[test]
753 fn outside_tmux_uses_inherited_stdio_for_attach() {
754 let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
755 let runner = Arc::new(FakeCommandRunner::new());
756 runner.push_inherit(Ok(CommandStatus {
757 success: true,
758 code: Some(0),
759 }));
760
761 unsafe {
762 std::env::remove_var("TMUX");
763 }
764
765 let tmux = Tmux::with_runner(runner.clone());
766 tmux.switch_or_attach("demo")
767 .expect("attach should succeed");
768
769 let recorded = runner.recorded();
770 assert_eq!(recorded.len(), 1);
771 assert_eq!(recorded[0].program, "tmux");
772 assert_eq!(recorded[0].args, vec!["attach-session", "-t", "demo"]);
773 assert_eq!(recorded[0].io_mode, IoMode::Inherit);
774 }
775
776 #[test]
777 fn list_sessions_returns_empty_when_tmux_server_is_not_running() {
778 let runner = Arc::new(FakeCommandRunner::new());
779 runner.push_capture(Ok(CommandOutput {
780 status: CommandStatus {
781 success: false,
782 code: Some(1),
783 },
784 stdout: Vec::new(),
785 stderr: b"no server running on /tmp/tmux-1000/default\n".to_vec(),
786 }));
787
788 let tmux = Tmux::with_runner(runner);
789 assert_eq!(
790 tmux.list_sessions().expect("query should succeed"),
791 Vec::<String>::new()
792 );
793 }
794
795 #[test]
796 fn list_sessions_returns_empty_when_tmux_socket_is_missing() {
797 let runner = Arc::new(FakeCommandRunner::new());
798 runner.push_capture(Ok(CommandOutput {
799 status: CommandStatus {
800 success: false,
801 code: Some(1),
802 },
803 stdout: Vec::new(),
804 stderr: b"error connecting to /tmp/tmux-1000/default (No such file or directory)\n"
805 .to_vec(),
806 }));
807
808 let tmux = Tmux::with_runner(runner);
809 assert_eq!(
810 tmux.list_sessions().expect("query should succeed"),
811 Vec::<String>::new()
812 );
813 }
814
815 #[test]
816 fn outside_tmux_has_no_current_session() {
817 let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
818 let runner = Arc::new(FakeCommandRunner::new());
819
820 unsafe {
821 std::env::remove_var("TMUX");
822 }
823
824 let tmux = Tmux::with_runner(runner.clone());
825 assert_eq!(tmux.current_session().expect("query should succeed"), None);
826 assert!(runner.recorded().is_empty());
827 }
828
829 #[test]
830 fn inside_tmux_uses_switch_client_with_captured_io() {
831 let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
832 let runner = Arc::new(FakeCommandRunner::new());
833 runner.push_capture(Ok(CommandOutput {
834 status: CommandStatus {
835 success: true,
836 code: Some(0),
837 },
838 stdout: Vec::new(),
839 stderr: Vec::new(),
840 }));
841
842 unsafe {
843 std::env::set_var("TMUX", "/tmp/tmux-test,123,0");
844 }
845
846 let tmux = Tmux::with_runner(runner.clone());
847 tmux.switch_or_attach("demo")
848 .expect("switch-client should succeed");
849
850 let recorded = runner.recorded();
851 assert_eq!(recorded.len(), 1);
852 assert_eq!(recorded[0].program, "tmux");
853 assert_eq!(recorded[0].args, vec!["switch-client", "-t", "demo"]);
854 assert_eq!(recorded[0].io_mode, IoMode::Capture);
855
856 unsafe {
857 std::env::remove_var("TMUX");
858 }
859 }
860
861 #[test]
862 fn inside_tmux_reads_current_session() {
863 let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
864 let runner = Arc::new(FakeCommandRunner::new());
865 runner.push_capture(Ok(CommandOutput {
866 status: CommandStatus {
867 success: true,
868 code: Some(0),
869 },
870 stdout: b"demo\n".to_vec(),
871 stderr: Vec::new(),
872 }));
873
874 unsafe {
875 std::env::set_var("TMUX", "/tmp/tmux-test,123,0");
876 }
877
878 let tmux = Tmux::with_runner(runner.clone());
879 assert_eq!(
880 tmux.current_session()
881 .expect("query should succeed")
882 .as_deref(),
883 Some("demo")
884 );
885
886 let recorded = runner.recorded();
887 assert_eq!(recorded.len(), 1);
888 assert_eq!(
889 recorded[0].args,
890 vec!["display-message", "-p", "#{session_name}"]
891 );
892
893 unsafe {
894 std::env::remove_var("TMUX");
895 }
896 }
897
898 #[test]
899 fn session_plan_emits_expected_tmux_commands() {
900 let runner = Arc::new(FakeCommandRunner::new());
901 runner.push_capture(ok_capture(Vec::new()));
902 runner.push_capture(ok_capture(b"%1\n".to_vec()));
903 runner.push_capture(ok_capture(Vec::new()));
904 runner.push_capture(ok_capture(Vec::new()));
905 runner.push_capture(ok_capture(Vec::new()));
906 runner.push_capture(ok_capture(b"%2\n".to_vec()));
907 runner.push_capture(ok_capture(Vec::new()));
908 runner.push_capture(ok_capture(Vec::new()));
909 runner.push_capture(ok_capture(b"%3\n".to_vec()));
910 runner.push_capture(ok_capture(Vec::new()));
911 runner.push_capture(ok_capture(Vec::new()));
912 runner.push_capture(ok_capture(Vec::new()));
913 runner.push_capture(ok_capture(Vec::new()));
914 runner.push_capture(ok_capture(Vec::new()));
915 runner.push_capture(ok_capture(b"%1\n".to_vec()));
916 runner.push_capture(ok_capture(Vec::new()));
917
918 let tmux = Tmux::with_runner(runner.clone());
919 let plan = SessionPlan {
920 session_name: "demo".to_owned(),
921 startup_window: "editor".to_owned(),
922 startup_pane: 0,
923 windows: vec![
924 WindowPlan {
925 name: "editor".to_owned(),
926 cwd: "/tmp/demo".into(),
927 pre_command: Some("source .venv/bin/activate".to_owned()),
928 command: Some("nvim".to_owned()),
929 layout: None,
930 synchronize: false,
931 panes: Vec::new(),
932 },
933 WindowPlan {
934 name: "run".to_owned(),
935 cwd: "/tmp/demo".into(),
936 pre_command: Some("source .venv/bin/activate".to_owned()),
937 command: None,
938 layout: Some("main-horizontal".to_owned()),
939 synchronize: true,
940 panes: vec![
941 PanePlan {
942 layout: None,
943 cwd: "/tmp/demo".into(),
944 command: Some("cargo run".to_owned()),
945 zoom: false,
946 },
947 PanePlan {
948 layout: Some(PaneLayout {
949 position: PanePosition::Right,
950 size: None,
951 }),
952 cwd: "/tmp/demo".into(),
953 command: Some("cargo test".to_owned()),
954 zoom: false,
955 },
956 ],
957 },
958 ],
959 };
960
961 tmux.create_session_from_plan(&plan)
962 .expect("session plan should succeed");
963
964 let recorded = runner.recorded();
965 assert_eq!(recorded[0].args[..4], ["new-session", "-d", "-s", "demo"]);
966 assert_eq!(
967 recorded[1].args,
968 vec!["list-panes", "-t", "demo:editor", "-F", "#{pane_id}"]
969 );
970 assert_eq!(
971 recorded[2].args,
972 vec!["send-keys", "-t", "%1", "source .venv/bin/activate", "C-m"]
973 );
974 assert_eq!(
975 recorded[3].args,
976 vec!["send-keys", "-t", "%1", "nvim", "C-m"]
977 );
978 assert_eq!(
979 recorded[4].args,
980 vec![
981 "new-window",
982 "-a",
983 "-t",
984 "demo:editor",
985 "-n",
986 "run",
987 "-c",
988 "/tmp/demo"
989 ]
990 );
991 assert_eq!(
992 recorded[5].args,
993 vec!["list-panes", "-t", "demo:run", "-F", "#{pane_id}"]
994 );
995 assert_eq!(
996 recorded[6].args,
997 vec!["send-keys", "-t", "%2", "source .venv/bin/activate", "C-m"]
998 );
999 assert_eq!(
1000 recorded[7].args,
1001 vec!["send-keys", "-t", "%2", "cargo run", "C-m"]
1002 );
1003 assert_eq!(
1004 recorded[8].args,
1005 vec![
1006 "split-window",
1007 "-t",
1008 "demo:run",
1009 "-P",
1010 "-F",
1011 "#{pane_id}",
1012 "-h",
1013 "-c",
1014 "/tmp/demo"
1015 ]
1016 );
1017 assert_eq!(
1018 recorded[9].args,
1019 vec!["send-keys", "-t", "%3", "source .venv/bin/activate", "C-m"]
1020 );
1021 assert_eq!(
1022 recorded[10].args,
1023 vec!["send-keys", "-t", "%3", "cargo test", "C-m"]
1024 );
1025 assert_eq!(
1026 recorded[11].args,
1027 vec!["select-layout", "-t", "demo:run", "main-horizontal"]
1028 );
1029 assert_eq!(
1030 recorded[12].args,
1031 vec![
1032 "set-window-option",
1033 "-t",
1034 "demo:run",
1035 "synchronize-panes",
1036 "on"
1037 ]
1038 );
1039 assert_eq!(
1040 recorded[13].args,
1041 vec!["select-window", "-t", "demo:editor"]
1042 );
1043 assert_eq!(
1044 recorded[14].args,
1045 vec!["list-panes", "-t", "demo:editor", "-F", "#{pane_id}"]
1046 );
1047 assert_eq!(recorded[15].args, vec!["select-pane", "-t", "%1"]);
1048 }
1049
1050 #[test]
1051 fn first_pane_cwd_is_used_when_creating_window() {
1052 let runner = Arc::new(FakeCommandRunner::new());
1053 runner.push_capture(ok_capture(Vec::new()));
1054 runner.push_capture(ok_capture(b"%1\n".to_vec()));
1055 runner.push_capture(ok_capture(Vec::new()));
1056 runner.push_capture(ok_capture(b"%2\n".to_vec()));
1057 runner.push_capture(ok_capture(Vec::new()));
1058 runner.push_capture(ok_capture(Vec::new()));
1059 runner.push_capture(ok_capture(b"%1\n%2\n".to_vec()));
1060 runner.push_capture(ok_capture(Vec::new()));
1061
1062 let tmux = Tmux::with_runner(runner.clone());
1063 let plan = SessionPlan {
1064 session_name: "demo".to_owned(),
1065 startup_window: "main".to_owned(),
1066 startup_pane: 0,
1067 windows: vec![WindowPlan {
1068 name: "main".to_owned(),
1069 cwd: "/tmp/demo".into(),
1070 pre_command: None,
1071 command: None,
1072 layout: None,
1073 synchronize: false,
1074 panes: vec![
1075 PanePlan {
1076 layout: None,
1077 cwd: "/tmp/demo/app".into(),
1078 command: Some("nvim".to_owned()),
1079 zoom: false,
1080 },
1081 PanePlan {
1082 layout: Some(PaneLayout {
1083 position: PanePosition::Right,
1084 size: None,
1085 }),
1086 cwd: "/tmp/demo/server".into(),
1087 command: Some("cargo run".to_owned()),
1088 zoom: false,
1089 },
1090 ],
1091 }],
1092 };
1093
1094 tmux.create_session_from_plan(&plan)
1095 .expect("session plan should succeed");
1096
1097 let recorded = runner.recorded();
1098 assert_eq!(
1099 recorded[0].args,
1100 vec![
1101 "new-session",
1102 "-d",
1103 "-s",
1104 "demo",
1105 "-c",
1106 "/tmp/demo/app",
1107 "-n",
1108 "main"
1109 ]
1110 );
1111 assert_eq!(
1112 recorded[3].args,
1113 vec![
1114 "split-window",
1115 "-t",
1116 "demo:main",
1117 "-P",
1118 "-F",
1119 "#{pane_id}",
1120 "-h",
1121 "-c",
1122 "/tmp/demo/server"
1123 ]
1124 );
1125 }
1126
1127 #[test]
1128 fn kill_session_uses_captured_tmux_command() {
1129 let runner = Arc::new(FakeCommandRunner::new());
1130 runner.push_capture(ok_capture(Vec::new()));
1131
1132 let tmux = Tmux::with_runner(runner.clone());
1133 tmux.kill_session("demo")
1134 .expect("kill-session should succeed");
1135
1136 let recorded = runner.recorded();
1137 assert_eq!(recorded.len(), 1);
1138 assert_eq!(recorded[0].program, "tmux");
1139 assert_eq!(recorded[0].args, vec!["kill-session", "-t", "demo"]);
1140 assert_eq!(recorded[0].io_mode, IoMode::Capture);
1141 }
1142
1143 #[test]
1144 fn capture_session_reads_windows_and_panes() {
1145 let runner = Arc::new(FakeCommandRunner::new());
1146 runner.push_capture(ok_capture(Vec::new()));
1147 runner.push_capture(ok_capture(b"@1\teditor\t1\n@2\trun\t0\n".to_vec()));
1148 runner.push_capture(ok_capture(b"off\n".to_vec()));
1149 runner.push_capture(ok_capture(
1150 b"0\t/tmp/demo\t1\t0\t0\t100\t40\n1\t/tmp/demo/server\t0\t50\t0\t50\t40\n".to_vec(),
1151 ));
1152 runner.push_capture(ok_capture(b"on\n".to_vec()));
1153 runner.push_capture(ok_capture(b"0\t/tmp/demo\t1\t0\t0\t100\t40\n".to_vec()));
1154
1155 let tmux = Tmux::with_runner(runner);
1156 let snapshot = tmux
1157 .capture_session("demo")
1158 .expect("capture should succeed");
1159
1160 assert_eq!(snapshot.session_name, "demo");
1161 assert_eq!(snapshot.active_window, "editor");
1162 assert_eq!(snapshot.active_pane, 0);
1163 assert_eq!(snapshot.active_path, std::path::PathBuf::from("/tmp/demo"));
1164 assert_eq!(snapshot.windows.len(), 2);
1165 assert_eq!(snapshot.windows[0].name, "editor");
1166 assert!(!snapshot.windows[0].synchronize);
1167 assert_eq!(snapshot.windows[0].panes.len(), 2);
1168 assert_eq!(
1169 snapshot.windows[0].panes[1].layout,
1170 Some(PaneLayout {
1171 position: PanePosition::Right,
1172 size: None,
1173 })
1174 );
1175 assert!(snapshot.windows[1].synchronize);
1176 }
1177
1178 #[test]
1179 fn startup_pane_uses_zero_based_offset_not_tmux_base_index() {
1180 let runner = Arc::new(FakeCommandRunner::new());
1181 runner.push_capture(ok_capture(Vec::new()));
1182 runner.push_capture(ok_capture(b"%10\n".to_vec()));
1183 runner.push_capture(ok_capture(Vec::new()));
1184 runner.push_capture(ok_capture(b"%11\n".to_vec()));
1185 runner.push_capture(ok_capture(Vec::new()));
1186 runner.push_capture(ok_capture(Vec::new()));
1187 runner.push_capture(ok_capture(b"%10\n%11\n".to_vec()));
1188 runner.push_capture(ok_capture(Vec::new()));
1189
1190 let tmux = Tmux::with_runner(runner.clone());
1191 let plan = SessionPlan {
1192 session_name: "demo".to_owned(),
1193 startup_window: "main".to_owned(),
1194 startup_pane: 1,
1195 windows: vec![WindowPlan {
1196 name: "main".to_owned(),
1197 cwd: "/tmp/demo".into(),
1198 pre_command: None,
1199 command: None,
1200 layout: None,
1201 synchronize: false,
1202 panes: vec![
1203 PanePlan {
1204 layout: None,
1205 cwd: "/tmp/demo".into(),
1206 command: Some("shell".to_owned()),
1207 zoom: false,
1208 },
1209 PanePlan {
1210 layout: Some(PaneLayout {
1211 position: PanePosition::Right,
1212 size: None,
1213 }),
1214 cwd: "/tmp/demo".into(),
1215 command: Some("tests".to_owned()),
1216 zoom: false,
1217 },
1218 ],
1219 }],
1220 };
1221
1222 tmux.create_session_from_plan(&plan)
1223 .expect("session plan should succeed");
1224
1225 let recorded = runner.recorded();
1226 assert_eq!(
1227 recorded[6].args,
1228 vec!["list-panes", "-t", "demo:main", "-F", "#{pane_id}"]
1229 );
1230 assert_eq!(recorded[7].args, vec!["select-pane", "-t", "%11"]);
1231 }
1232
1233 #[test]
1234 fn zoomed_pane_emits_resize_pane_command() {
1235 let runner = Arc::new(FakeCommandRunner::new());
1236 runner.push_capture(ok_capture(Vec::new()));
1237 runner.push_capture(ok_capture(b"%20\n".to_vec()));
1238 runner.push_capture(ok_capture(Vec::new()));
1239 runner.push_capture(ok_capture(b"%21\n".to_vec()));
1240 runner.push_capture(ok_capture(Vec::new()));
1241 runner.push_capture(ok_capture(Vec::new()));
1242 runner.push_capture(ok_capture(Vec::new()));
1243 runner.push_capture(ok_capture(b"%20\n%21\n".to_vec()));
1244 runner.push_capture(ok_capture(Vec::new()));
1245
1246 let tmux = Tmux::with_runner(runner.clone());
1247 let plan = SessionPlan {
1248 session_name: "demo".to_owned(),
1249 startup_window: "main".to_owned(),
1250 startup_pane: 0,
1251 windows: vec![WindowPlan {
1252 name: "main".to_owned(),
1253 cwd: "/tmp/demo".into(),
1254 pre_command: None,
1255 command: None,
1256 layout: None,
1257 synchronize: false,
1258 panes: vec![
1259 PanePlan {
1260 layout: None,
1261 cwd: "/tmp/demo".into(),
1262 command: Some("shell".to_owned()),
1263 zoom: false,
1264 },
1265 PanePlan {
1266 layout: Some(PaneLayout {
1267 position: PanePosition::Right,
1268 size: None,
1269 }),
1270 cwd: "/tmp/demo".into(),
1271 command: Some("tests".to_owned()),
1272 zoom: true,
1273 },
1274 ],
1275 }],
1276 };
1277
1278 tmux.create_session_from_plan(&plan)
1279 .expect("session plan should succeed");
1280
1281 let recorded = runner.recorded();
1282 assert_eq!(recorded[5].args, vec!["resize-pane", "-Z", "-t", "%21"]);
1283 }
1284
1285 fn ok_capture(stdout: Vec<u8>) -> std::io::Result<CommandOutput> {
1286 Ok(CommandOutput {
1287 status: CommandStatus {
1288 success: true,
1289 code: Some(0),
1290 },
1291 stdout,
1292 stderr: Vec::new(),
1293 })
1294 }
1295}