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 stderr.contains("no server running") {
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(&plan.session_name, &first_window.name, &first_window.cwd)?;
196 self.configure_panes(&plan.session_name, &first_window.name, first_window)?;
197
198 let mut previous_window = first_window.name.as_str();
199 for window in plan.windows.iter().skip(1) {
200 self.new_window_after(
201 &plan.session_name,
202 previous_window,
203 &window.name,
204 &window.cwd,
205 )?;
206 self.configure_panes(&plan.session_name, &window.name, window)?;
207 previous_window = &window.name;
208 }
209
210 self.select_window(&plan.session_name, &plan.startup_window)?;
211 self.select_pane_by_offset(&plan.session_name, &plan.startup_window, plan.startup_pane)?;
212 Ok(())
213 }
214
215 pub fn switch_or_attach(&self, session: &str) -> Result<()> {
216 if util::inside_tmux() {
217 self.run_tmux(["switch-client", "-t", session])
218 .context("failed to execute tmux switch-client")
219 } else {
220 let args = vec![
221 "attach-session".to_owned(),
222 "-t".to_owned(),
223 session.to_owned(),
224 ];
225
226 let status = self
227 .runner
228 .run_inherit("tmux", &args)
229 .context("failed to execute tmux attach-session")?;
230
231 if status.success {
232 Ok(())
233 } else {
234 bail!("tmux attach-session failed with status {:?}", status.code)
235 }
236 }
237 }
238
239 pub fn kill_session(&self, session: &str) -> Result<()> {
240 self.run_tmux(["kill-session", "-t", session])
241 .context("failed to execute tmux kill-session")
242 }
243
244 pub fn capture_session(&self, session: &str) -> Result<SessionSnapshot> {
245 self.ensure_session_exists(session)?;
246
247 let windows = self.list_windows(session)?;
248 let active_window_name = windows
249 .iter()
250 .find(|window| window.active)
251 .or_else(|| windows.first())
252 .context("tmux session did not contain any windows")?;
253 let active_window_name = active_window_name.name.clone();
254
255 let mut captured_windows = Vec::with_capacity(windows.len());
256 let mut active_pane = None;
257 let mut active_path = None;
258
259 for window in windows {
260 let synchronize = self.window_synchronize(&window.id)?;
261 let panes = self.list_pane_records(&window.id)?;
262 let panes = infer_pane_layouts(panes);
263
264 if window.active {
265 let active = panes
266 .iter()
267 .enumerate()
268 .find(|(_, pane)| pane.active)
269 .or_else(|| panes.first().map(|pane| (0, pane)))
270 .context("active tmux window did not contain any panes")?;
271 active_pane = Some(active.0);
272 active_path = Some(active.1.cwd.clone());
273 }
274
275 captured_windows.push(WindowSnapshot {
276 name: window.name,
277 synchronize,
278 active: window.active,
279 panes,
280 });
281 }
282
283 let active_path =
284 active_path.context("could not determine the active pane path for the tmux session")?;
285
286 Ok(SessionSnapshot {
287 session_name: session.to_owned(),
288 active_window: active_window_name,
289 active_pane: active_pane.unwrap_or(0),
290 active_path,
291 windows: captured_windows,
292 })
293 }
294
295 fn create_session_with_window(
296 &self,
297 session: &str,
298 window: &str,
299 directory: &Path,
300 ) -> Result<()> {
301 let directory = util::path_to_string(directory)?;
302 self.run_tmux([
303 "new-session",
304 "-d",
305 "-s",
306 session,
307 "-c",
308 &directory,
309 "-n",
310 window,
311 ])
312 .context("failed to execute tmux new-session")
313 }
314
315 fn new_window_after(
316 &self,
317 session: &str,
318 after_window: &str,
319 window: &str,
320 directory: &Path,
321 ) -> Result<()> {
322 let directory = util::path_to_string(directory)?;
323 let target = format!("{session}:{after_window}");
324 self.run_tmux([
325 "new-window",
326 "-a",
327 "-t",
328 &target,
329 "-n",
330 window,
331 "-c",
332 &directory,
333 ])
334 .context("failed to execute tmux new-window")
335 }
336
337 fn send_keys_to_target(&self, target: &str, command: &str) -> Result<()> {
338 self.run_tmux(["send-keys", "-t", target, command, "C-m"])
339 .context("failed to execute tmux send-keys")
340 }
341
342 fn split_window(&self, target: &str, layout: &PaneLayout, directory: &Path) -> Result<String> {
343 let directory = util::path_to_string(directory)?;
344 let mut args = vec![
345 "split-window".to_owned(),
346 "-t".to_owned(),
347 target.to_owned(),
348 "-P".to_owned(),
349 "-F".to_owned(),
350 "#{pane_id}".to_owned(),
351 ];
352
353 match layout.position {
354 PanePosition::Right | PanePosition::Left => args.push("-h".to_owned()),
355 PanePosition::Bottom | PanePosition::Top => args.push("-v".to_owned()),
356 }
357
358 match layout.position {
359 PanePosition::Left | PanePosition::Top => args.push("-b".to_owned()),
360 PanePosition::Right | PanePosition::Bottom => {}
361 }
362
363 if let Some(size) = &layout.size {
364 args.push("-l".to_owned());
365 args.push(size.clone());
366 }
367
368 args.push("-c".to_owned());
369 args.push(directory);
370
371 let output = self
372 .runner
373 .run_capture("tmux", &args)
374 .context("failed to execute tmux split-window")?;
375
376 if !output.status.success {
377 let stderr = String::from_utf8_lossy(&output.stderr);
378 bail!("tmux split-window failed: {}", stderr.trim());
379 }
380
381 let pane_id =
382 String::from_utf8(output.stdout).context("tmux split-window output was not utf-8")?;
383 Ok(pane_id.trim().to_owned())
384 }
385
386 fn select_layout(&self, target: &str, layout: &str) -> Result<()> {
387 self.run_tmux(["select-layout", "-t", target, layout])
388 .context("failed to execute tmux select-layout")
389 }
390
391 fn select_window(&self, session: &str, window: &str) -> Result<()> {
392 let target = format!("{session}:{window}");
393 self.run_tmux(["select-window", "-t", &target])
394 .context("failed to execute tmux select-window")
395 }
396
397 fn select_pane_target(&self, target: &str) -> Result<()> {
398 self.run_tmux(["select-pane", "-t", target])
399 .context("failed to execute tmux select-pane")
400 }
401
402 fn set_synchronize_panes(&self, session: &str, window: &str, enabled: bool) -> Result<()> {
403 let target = format!("{session}:{window}");
404 let value = if enabled { "on" } else { "off" };
405 self.run_tmux([
406 "set-window-option",
407 "-t",
408 &target,
409 "synchronize-panes",
410 value,
411 ])
412 .context("failed to execute tmux set-window-option")
413 }
414
415 fn zoom_pane(&self, target: &str) -> Result<()> {
416 self.run_tmux(["resize-pane", "-Z", "-t", target])
417 .context("failed to execute tmux resize-pane -Z")
418 }
419
420 fn configure_panes(
421 &self,
422 session: &str,
423 window: &str,
424 plan: &crate::templates::WindowPlan,
425 ) -> Result<()> {
426 let target = format!("{session}:{window}");
427 let pane_ids = self.list_panes(&target)?;
428 let first_pane_target = pane_ids
429 .first()
430 .cloned()
431 .context("tmux window did not contain an initial pane")?;
432 let mut zoom_target = if plan.panes.is_empty() {
433 None
434 } else if plan.panes[0].zoom {
435 Some(first_pane_target.clone())
436 } else {
437 None
438 };
439
440 if plan.panes.is_empty() {
441 if let Some(pre_command) = &plan.pre_command {
442 self.send_keys_to_target(&first_pane_target, pre_command)?;
443 }
444 if let Some(command) = &plan.command {
445 self.send_keys_to_target(&first_pane_target, command)?;
446 }
447 if plan.synchronize {
448 self.set_synchronize_panes(session, window, true)?;
449 }
450 if let Some(target) = zoom_target.as_deref() {
451 self.zoom_pane(target)?;
452 }
453 return Ok(());
454 }
455
456 if let Some(pre_command) = &plan.pre_command {
457 self.send_keys_to_target(&first_pane_target, pre_command)
458 .context("failed to execute tmux send-keys for first pane pre_command")?;
459 }
460 if let Some(command) = &plan.panes[0].command {
461 self.send_keys_to_target(&first_pane_target, command)
462 .context("failed to execute tmux send-keys for first pane")?;
463 }
464
465 for (pane_index, pane) in plan.panes.iter().enumerate().skip(1) {
466 let layout = pane.layout.as_ref().ok_or_else(|| {
467 anyhow::anyhow!(
468 "pane {} in window \"{}\" is missing a layout",
469 pane_index,
470 window
471 )
472 })?;
473 let pane_target = self.split_window(&target, layout, &pane.cwd)?;
474 if let Some(pre_command) = &plan.pre_command {
475 self.send_keys_to_target(&pane_target, pre_command)
476 .context("failed to execute tmux send-keys for split pane pre_command")?;
477 }
478 if let Some(command) = &pane.command {
479 self.send_keys_to_target(&pane_target, command)
480 .context("failed to execute tmux send-keys for split pane")?;
481 }
482 if pane.zoom {
483 zoom_target = Some(pane_target.clone());
484 }
485 }
486
487 if let Some(layout) = &plan.layout {
488 self.select_layout(&target, layout)?;
489 }
490
491 if plan.synchronize {
492 self.set_synchronize_panes(session, window, true)?;
493 }
494
495 if let Some(target) = zoom_target.as_deref() {
496 self.zoom_pane(target)?;
497 }
498
499 Ok(())
500 }
501
502 fn list_panes(&self, target: &str) -> Result<Vec<String>> {
503 let output = self
504 .run_tmux_capture(["list-panes", "-t", target, "-F", "#{pane_id}"])
505 .context("failed to execute tmux list-panes")?;
506
507 if !output.status.success {
508 let stderr = String::from_utf8_lossy(&output.stderr);
509 bail!("tmux list-panes failed: {}", stderr.trim());
510 }
511
512 let stdout =
513 String::from_utf8(output.stdout).context("tmux list-panes output was not utf-8")?;
514 Ok(stdout
515 .lines()
516 .map(str::trim)
517 .filter(|line| !line.is_empty())
518 .map(ToOwned::to_owned)
519 .collect())
520 }
521
522 fn select_pane_by_offset(&self, session: &str, window: &str, pane_offset: usize) -> Result<()> {
523 let target = format!("{session}:{window}");
524 let panes = self.list_panes(&target)?;
525 let pane = panes.get(pane_offset).with_context(|| {
526 format!(
527 "startup pane offset {} was not found in window {}",
528 pane_offset, target
529 )
530 })?;
531 self.select_pane_target(pane)
532 }
533
534 fn run_tmux<const N: usize>(&self, args: [&str; N]) -> Result<()> {
535 let output = self.run_tmux_capture(args)?;
536
537 if output.status.success {
538 Ok(())
539 } else {
540 let stderr = String::from_utf8_lossy(&output.stderr);
541 bail!("{}", stderr.trim())
542 }
543 }
544
545 fn run_tmux_capture<const N: usize>(&self, args: [&str; N]) -> Result<CommandOutput> {
546 let args = args.into_iter().map(ToOwned::to_owned).collect::<Vec<_>>();
547 self.runner.run_capture("tmux", &args).map_err(Into::into)
548 }
549
550 fn list_windows(&self, session: &str) -> Result<Vec<WindowRecord>> {
551 let output = self
552 .run_tmux_capture([
553 "list-windows",
554 "-t",
555 session,
556 "-F",
557 "#{window_id}\t#{window_name}\t#{window_active}",
558 ])
559 .context("failed to execute tmux list-windows")?;
560
561 if !output.status.success {
562 let stderr = String::from_utf8_lossy(&output.stderr);
563 bail!("tmux list-windows failed: {}", stderr.trim());
564 }
565
566 let stdout =
567 String::from_utf8(output.stdout).context("tmux list-windows output was not utf-8")?;
568 stdout
569 .lines()
570 .filter(|line| !line.trim().is_empty())
571 .map(parse_window_record)
572 .collect()
573 }
574
575 fn window_synchronize(&self, window_id: &str) -> Result<bool> {
576 let output = self
577 .run_tmux_capture([
578 "show-window-options",
579 "-t",
580 window_id,
581 "-v",
582 "synchronize-panes",
583 ])
584 .context("failed to execute tmux show-window-options")?;
585
586 if !output.status.success {
587 let stderr = String::from_utf8_lossy(&output.stderr);
588 bail!("tmux show-window-options failed: {}", stderr.trim());
589 }
590
591 let stdout = String::from_utf8(output.stdout)
592 .context("tmux show-window-options output was not utf-8")?;
593 Ok(stdout.trim() == "on")
594 }
595
596 fn list_pane_records(&self, window_id: &str) -> Result<Vec<PaneRecord>> {
597 let output = self
598 .run_tmux_capture([
599 "list-panes",
600 "-t",
601 window_id,
602 "-F",
603 "#{pane_index}\t#{pane_current_path}\t#{pane_active}\t#{pane_left}\t#{pane_top}\t#{pane_width}\t#{pane_height}",
604 ])
605 .context("failed to execute tmux list-panes")?;
606
607 if !output.status.success {
608 let stderr = String::from_utf8_lossy(&output.stderr);
609 bail!("tmux list-panes failed: {}", stderr.trim());
610 }
611
612 let stdout =
613 String::from_utf8(output.stdout).context("tmux list-panes output was not utf-8")?;
614 let mut panes = stdout
615 .lines()
616 .filter(|line| !line.trim().is_empty())
617 .map(parse_pane_record)
618 .collect::<Result<Vec<_>>>()?;
619 panes.sort_by_key(|pane| pane.index);
620 Ok(panes)
621 }
622}
623
624fn parse_window_record(line: &str) -> Result<WindowRecord> {
625 let mut parts = line.splitn(3, '\t');
626 let id = parts.next().context("missing tmux window id")?.to_owned();
627 let name = parts.next().context("missing tmux window name")?.to_owned();
628 let active = match parts.next().context("missing tmux window active flag")? {
629 "1" => true,
630 "0" => false,
631 other => bail!("invalid tmux window active flag: {other}"),
632 };
633
634 Ok(WindowRecord { id, name, active })
635}
636
637fn parse_pane_record(line: &str) -> Result<PaneRecord> {
638 let mut parts = line.splitn(7, '\t');
639 let index = parts
640 .next()
641 .context("missing tmux pane index")?
642 .parse()
643 .context("tmux pane index was not a number")?;
644 let cwd = std::path::PathBuf::from(parts.next().context("missing tmux pane cwd")?);
645 let active = match parts.next().context("missing tmux pane active flag")? {
646 "1" => true,
647 "0" => false,
648 other => bail!("invalid tmux pane active flag: {other}"),
649 };
650 let left = parts
651 .next()
652 .context("missing tmux pane left coordinate")?
653 .parse()
654 .context("tmux pane left was not a number")?;
655 let top = parts
656 .next()
657 .context("missing tmux pane top coordinate")?
658 .parse()
659 .context("tmux pane top was not a number")?;
660 let width = parts
661 .next()
662 .context("missing tmux pane width")?
663 .parse()
664 .context("tmux pane width was not a number")?;
665 let height = parts
666 .next()
667 .context("missing tmux pane height")?
668 .parse()
669 .context("tmux pane height was not a number")?;
670
671 Ok(PaneRecord {
672 index,
673 cwd,
674 active,
675 left,
676 top,
677 width,
678 height,
679 })
680}
681
682fn infer_pane_layouts(panes: Vec<PaneRecord>) -> Vec<PaneSnapshot> {
683 let mut inferred = Vec::with_capacity(panes.len());
684
685 for pane in panes {
686 let layout = if inferred.is_empty() {
687 None
688 } else {
689 Some(PaneLayout {
690 position: infer_pane_position(&pane, &inferred),
691 size: None,
692 })
693 };
694
695 inferred.push(PaneSnapshot {
696 cwd: pane.cwd,
697 active: pane.active,
698 layout,
699 });
700 }
701
702 inferred
703}
704
705fn infer_pane_position(pane: &PaneRecord, previous: &[PaneSnapshot]) -> PanePosition {
706 let _ = previous;
707 if pane.left > 0 && pane.top == 0 {
708 PanePosition::Right
709 } else if pane.top > 0 && pane.left == 0 {
710 PanePosition::Bottom
711 } else if pane.left > 0 {
712 PanePosition::Right
713 } else if pane.top > 0 {
714 PanePosition::Bottom
715 } else {
716 PanePosition::Right
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use std::sync::Arc;
723 use std::sync::Mutex;
724
725 use crate::process::{CommandOutput, CommandStatus, FakeCommandRunner, IoMode};
726 use crate::templates::{PaneLayout, PanePlan, PanePosition, SessionPlan, WindowPlan};
727
728 use super::Tmux;
729
730 static TMUX_ENV_LOCK: Mutex<()> = Mutex::new(());
731
732 #[test]
733 fn outside_tmux_uses_inherited_stdio_for_attach() {
734 let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
735 let runner = Arc::new(FakeCommandRunner::new());
736 runner.push_inherit(Ok(CommandStatus {
737 success: true,
738 code: Some(0),
739 }));
740
741 unsafe {
742 std::env::remove_var("TMUX");
743 }
744
745 let tmux = Tmux::with_runner(runner.clone());
746 tmux.switch_or_attach("demo")
747 .expect("attach should succeed");
748
749 let recorded = runner.recorded();
750 assert_eq!(recorded.len(), 1);
751 assert_eq!(recorded[0].program, "tmux");
752 assert_eq!(recorded[0].args, vec!["attach-session", "-t", "demo"]);
753 assert_eq!(recorded[0].io_mode, IoMode::Inherit);
754 }
755
756 #[test]
757 fn outside_tmux_has_no_current_session() {
758 let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
759 let runner = Arc::new(FakeCommandRunner::new());
760
761 unsafe {
762 std::env::remove_var("TMUX");
763 }
764
765 let tmux = Tmux::with_runner(runner.clone());
766 assert_eq!(tmux.current_session().expect("query should succeed"), None);
767 assert!(runner.recorded().is_empty());
768 }
769
770 #[test]
771 fn inside_tmux_uses_switch_client_with_captured_io() {
772 let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
773 let runner = Arc::new(FakeCommandRunner::new());
774 runner.push_capture(Ok(CommandOutput {
775 status: CommandStatus {
776 success: true,
777 code: Some(0),
778 },
779 stdout: Vec::new(),
780 stderr: Vec::new(),
781 }));
782
783 unsafe {
784 std::env::set_var("TMUX", "/tmp/tmux-test,123,0");
785 }
786
787 let tmux = Tmux::with_runner(runner.clone());
788 tmux.switch_or_attach("demo")
789 .expect("switch-client should succeed");
790
791 let recorded = runner.recorded();
792 assert_eq!(recorded.len(), 1);
793 assert_eq!(recorded[0].program, "tmux");
794 assert_eq!(recorded[0].args, vec!["switch-client", "-t", "demo"]);
795 assert_eq!(recorded[0].io_mode, IoMode::Capture);
796
797 unsafe {
798 std::env::remove_var("TMUX");
799 }
800 }
801
802 #[test]
803 fn inside_tmux_reads_current_session() {
804 let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
805 let runner = Arc::new(FakeCommandRunner::new());
806 runner.push_capture(Ok(CommandOutput {
807 status: CommandStatus {
808 success: true,
809 code: Some(0),
810 },
811 stdout: b"demo\n".to_vec(),
812 stderr: Vec::new(),
813 }));
814
815 unsafe {
816 std::env::set_var("TMUX", "/tmp/tmux-test,123,0");
817 }
818
819 let tmux = Tmux::with_runner(runner.clone());
820 assert_eq!(
821 tmux.current_session()
822 .expect("query should succeed")
823 .as_deref(),
824 Some("demo")
825 );
826
827 let recorded = runner.recorded();
828 assert_eq!(recorded.len(), 1);
829 assert_eq!(
830 recorded[0].args,
831 vec!["display-message", "-p", "#{session_name}"]
832 );
833
834 unsafe {
835 std::env::remove_var("TMUX");
836 }
837 }
838
839 #[test]
840 fn session_plan_emits_expected_tmux_commands() {
841 let runner = Arc::new(FakeCommandRunner::new());
842 runner.push_capture(ok_capture(Vec::new()));
843 runner.push_capture(ok_capture(b"%1\n".to_vec()));
844 runner.push_capture(ok_capture(Vec::new()));
845 runner.push_capture(ok_capture(Vec::new()));
846 runner.push_capture(ok_capture(Vec::new()));
847 runner.push_capture(ok_capture(b"%2\n".to_vec()));
848 runner.push_capture(ok_capture(Vec::new()));
849 runner.push_capture(ok_capture(Vec::new()));
850 runner.push_capture(ok_capture(b"%3\n".to_vec()));
851 runner.push_capture(ok_capture(Vec::new()));
852 runner.push_capture(ok_capture(Vec::new()));
853 runner.push_capture(ok_capture(Vec::new()));
854 runner.push_capture(ok_capture(Vec::new()));
855 runner.push_capture(ok_capture(Vec::new()));
856 runner.push_capture(ok_capture(b"%1\n".to_vec()));
857 runner.push_capture(ok_capture(Vec::new()));
858
859 let tmux = Tmux::with_runner(runner.clone());
860 let plan = SessionPlan {
861 session_name: "demo".to_owned(),
862 startup_window: "editor".to_owned(),
863 startup_pane: 0,
864 windows: vec![
865 WindowPlan {
866 name: "editor".to_owned(),
867 cwd: "/tmp/demo".into(),
868 pre_command: Some("source .venv/bin/activate".to_owned()),
869 command: Some("nvim".to_owned()),
870 layout: None,
871 synchronize: false,
872 panes: Vec::new(),
873 },
874 WindowPlan {
875 name: "run".to_owned(),
876 cwd: "/tmp/demo".into(),
877 pre_command: Some("source .venv/bin/activate".to_owned()),
878 command: None,
879 layout: Some("main-horizontal".to_owned()),
880 synchronize: true,
881 panes: vec![
882 PanePlan {
883 layout: None,
884 cwd: "/tmp/demo".into(),
885 command: Some("cargo run".to_owned()),
886 zoom: false,
887 },
888 PanePlan {
889 layout: Some(PaneLayout {
890 position: PanePosition::Right,
891 size: None,
892 }),
893 cwd: "/tmp/demo".into(),
894 command: Some("cargo test".to_owned()),
895 zoom: false,
896 },
897 ],
898 },
899 ],
900 };
901
902 tmux.create_session_from_plan(&plan)
903 .expect("session plan should succeed");
904
905 let recorded = runner.recorded();
906 assert_eq!(recorded[0].args[..4], ["new-session", "-d", "-s", "demo"]);
907 assert_eq!(
908 recorded[1].args,
909 vec!["list-panes", "-t", "demo:editor", "-F", "#{pane_id}"]
910 );
911 assert_eq!(
912 recorded[2].args,
913 vec!["send-keys", "-t", "%1", "source .venv/bin/activate", "C-m"]
914 );
915 assert_eq!(
916 recorded[3].args,
917 vec!["send-keys", "-t", "%1", "nvim", "C-m"]
918 );
919 assert_eq!(
920 recorded[4].args,
921 vec![
922 "new-window",
923 "-a",
924 "-t",
925 "demo:editor",
926 "-n",
927 "run",
928 "-c",
929 "/tmp/demo"
930 ]
931 );
932 assert_eq!(
933 recorded[5].args,
934 vec!["list-panes", "-t", "demo:run", "-F", "#{pane_id}"]
935 );
936 assert_eq!(
937 recorded[6].args,
938 vec!["send-keys", "-t", "%2", "source .venv/bin/activate", "C-m"]
939 );
940 assert_eq!(
941 recorded[7].args,
942 vec!["send-keys", "-t", "%2", "cargo run", "C-m"]
943 );
944 assert_eq!(
945 recorded[8].args,
946 vec![
947 "split-window",
948 "-t",
949 "demo:run",
950 "-P",
951 "-F",
952 "#{pane_id}",
953 "-h",
954 "-c",
955 "/tmp/demo"
956 ]
957 );
958 assert_eq!(
959 recorded[9].args,
960 vec!["send-keys", "-t", "%3", "source .venv/bin/activate", "C-m"]
961 );
962 assert_eq!(
963 recorded[10].args,
964 vec!["send-keys", "-t", "%3", "cargo test", "C-m"]
965 );
966 assert_eq!(
967 recorded[11].args,
968 vec!["select-layout", "-t", "demo:run", "main-horizontal"]
969 );
970 assert_eq!(
971 recorded[12].args,
972 vec![
973 "set-window-option",
974 "-t",
975 "demo:run",
976 "synchronize-panes",
977 "on"
978 ]
979 );
980 assert_eq!(
981 recorded[13].args,
982 vec!["select-window", "-t", "demo:editor"]
983 );
984 assert_eq!(
985 recorded[14].args,
986 vec!["list-panes", "-t", "demo:editor", "-F", "#{pane_id}"]
987 );
988 assert_eq!(recorded[15].args, vec!["select-pane", "-t", "%1"]);
989 }
990
991 #[test]
992 fn kill_session_uses_captured_tmux_command() {
993 let runner = Arc::new(FakeCommandRunner::new());
994 runner.push_capture(ok_capture(Vec::new()));
995
996 let tmux = Tmux::with_runner(runner.clone());
997 tmux.kill_session("demo")
998 .expect("kill-session should succeed");
999
1000 let recorded = runner.recorded();
1001 assert_eq!(recorded.len(), 1);
1002 assert_eq!(recorded[0].program, "tmux");
1003 assert_eq!(recorded[0].args, vec!["kill-session", "-t", "demo"]);
1004 assert_eq!(recorded[0].io_mode, IoMode::Capture);
1005 }
1006
1007 #[test]
1008 fn capture_session_reads_windows_and_panes() {
1009 let runner = Arc::new(FakeCommandRunner::new());
1010 runner.push_capture(ok_capture(Vec::new()));
1011 runner.push_capture(ok_capture(b"@1\teditor\t1\n@2\trun\t0\n".to_vec()));
1012 runner.push_capture(ok_capture(b"off\n".to_vec()));
1013 runner.push_capture(ok_capture(
1014 b"0\t/tmp/demo\t1\t0\t0\t100\t40\n1\t/tmp/demo/server\t0\t50\t0\t50\t40\n".to_vec(),
1015 ));
1016 runner.push_capture(ok_capture(b"on\n".to_vec()));
1017 runner.push_capture(ok_capture(b"0\t/tmp/demo\t1\t0\t0\t100\t40\n".to_vec()));
1018
1019 let tmux = Tmux::with_runner(runner);
1020 let snapshot = tmux
1021 .capture_session("demo")
1022 .expect("capture should succeed");
1023
1024 assert_eq!(snapshot.session_name, "demo");
1025 assert_eq!(snapshot.active_window, "editor");
1026 assert_eq!(snapshot.active_pane, 0);
1027 assert_eq!(snapshot.active_path, std::path::PathBuf::from("/tmp/demo"));
1028 assert_eq!(snapshot.windows.len(), 2);
1029 assert_eq!(snapshot.windows[0].name, "editor");
1030 assert!(!snapshot.windows[0].synchronize);
1031 assert_eq!(snapshot.windows[0].panes.len(), 2);
1032 assert_eq!(
1033 snapshot.windows[0].panes[1].layout,
1034 Some(PaneLayout {
1035 position: PanePosition::Right,
1036 size: None,
1037 })
1038 );
1039 assert!(snapshot.windows[1].synchronize);
1040 }
1041
1042 #[test]
1043 fn startup_pane_uses_zero_based_offset_not_tmux_base_index() {
1044 let runner = Arc::new(FakeCommandRunner::new());
1045 runner.push_capture(ok_capture(Vec::new()));
1046 runner.push_capture(ok_capture(b"%10\n".to_vec()));
1047 runner.push_capture(ok_capture(Vec::new()));
1048 runner.push_capture(ok_capture(b"%11\n".to_vec()));
1049 runner.push_capture(ok_capture(Vec::new()));
1050 runner.push_capture(ok_capture(Vec::new()));
1051 runner.push_capture(ok_capture(b"%10\n%11\n".to_vec()));
1052 runner.push_capture(ok_capture(Vec::new()));
1053
1054 let tmux = Tmux::with_runner(runner.clone());
1055 let plan = SessionPlan {
1056 session_name: "demo".to_owned(),
1057 startup_window: "main".to_owned(),
1058 startup_pane: 1,
1059 windows: vec![WindowPlan {
1060 name: "main".to_owned(),
1061 cwd: "/tmp/demo".into(),
1062 pre_command: None,
1063 command: None,
1064 layout: None,
1065 synchronize: false,
1066 panes: vec![
1067 PanePlan {
1068 layout: None,
1069 cwd: "/tmp/demo".into(),
1070 command: Some("shell".to_owned()),
1071 zoom: false,
1072 },
1073 PanePlan {
1074 layout: Some(PaneLayout {
1075 position: PanePosition::Right,
1076 size: None,
1077 }),
1078 cwd: "/tmp/demo".into(),
1079 command: Some("tests".to_owned()),
1080 zoom: false,
1081 },
1082 ],
1083 }],
1084 };
1085
1086 tmux.create_session_from_plan(&plan)
1087 .expect("session plan should succeed");
1088
1089 let recorded = runner.recorded();
1090 assert_eq!(
1091 recorded[6].args,
1092 vec!["list-panes", "-t", "demo:main", "-F", "#{pane_id}"]
1093 );
1094 assert_eq!(recorded[7].args, vec!["select-pane", "-t", "%11"]);
1095 }
1096
1097 #[test]
1098 fn zoomed_pane_emits_resize_pane_command() {
1099 let runner = Arc::new(FakeCommandRunner::new());
1100 runner.push_capture(ok_capture(Vec::new()));
1101 runner.push_capture(ok_capture(b"%20\n".to_vec()));
1102 runner.push_capture(ok_capture(Vec::new()));
1103 runner.push_capture(ok_capture(b"%21\n".to_vec()));
1104 runner.push_capture(ok_capture(Vec::new()));
1105 runner.push_capture(ok_capture(Vec::new()));
1106 runner.push_capture(ok_capture(Vec::new()));
1107 runner.push_capture(ok_capture(b"%20\n%21\n".to_vec()));
1108 runner.push_capture(ok_capture(Vec::new()));
1109
1110 let tmux = Tmux::with_runner(runner.clone());
1111 let plan = SessionPlan {
1112 session_name: "demo".to_owned(),
1113 startup_window: "main".to_owned(),
1114 startup_pane: 0,
1115 windows: vec![WindowPlan {
1116 name: "main".to_owned(),
1117 cwd: "/tmp/demo".into(),
1118 pre_command: None,
1119 command: None,
1120 layout: None,
1121 synchronize: false,
1122 panes: vec![
1123 PanePlan {
1124 layout: None,
1125 cwd: "/tmp/demo".into(),
1126 command: Some("shell".to_owned()),
1127 zoom: false,
1128 },
1129 PanePlan {
1130 layout: Some(PaneLayout {
1131 position: PanePosition::Right,
1132 size: None,
1133 }),
1134 cwd: "/tmp/demo".into(),
1135 command: Some("tests".to_owned()),
1136 zoom: true,
1137 },
1138 ],
1139 }],
1140 };
1141
1142 tmux.create_session_from_plan(&plan)
1143 .expect("session plan should succeed");
1144
1145 let recorded = runner.recorded();
1146 assert_eq!(recorded[5].args, vec!["resize-pane", "-Z", "-t", "%21"]);
1147 }
1148
1149 fn ok_capture(stdout: Vec<u8>) -> std::io::Result<CommandOutput> {
1150 Ok(CommandOutput {
1151 status: CommandStatus {
1152 success: true,
1153 code: Some(0),
1154 },
1155 stdout,
1156 stderr: Vec::new(),
1157 })
1158 }
1159}