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