1mod automation;
2mod capture;
3mod clients;
4mod formats;
5mod interactive;
6mod options;
7mod popup;
8mod sessions;
9mod target;
10
11use std::ffi::{OsStr, OsString};
12use std::fs::{self, OpenOptions};
13use std::io::Write;
14use std::num::NonZeroU64;
15#[cfg(unix)]
16use std::os::unix::ffi::OsStringExt;
17#[cfg(unix)]
18use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
19#[cfg(windows)]
20use std::os::windows::ffi::OsStringExt;
21use std::path::{Path, PathBuf};
22use std::process::{Command as ProcessCommand, Stdio};
23
24use base64::Engine as _;
25use clap::{Parser, Subcommand};
26use embers_client::{
27 ClientState, CommandContext, CommandRunner, NavigationDirection, PanePlacement, PaneTitles,
28 PresentationModel, SplitResize, drawn_viewport, spawn_pty_size, window_number, window_position,
29};
30use embers_core::{
31 BufferId, FloatingId, MuxError, NodeId, PtySize, Result, SessionId, SplitDirection,
32 new_request_id,
33};
34use embers_protocol::{
35 BufferHistoryPlacement, BufferHistoryScope, BufferLocation, BufferLocationAttachment,
36 BufferLocationResponse, BufferPipeRecord, BufferPipeState, BufferPipeStopReason,
37 BufferRecordState, BufferRequest, BufferResponse, ClientMessage, ClientRecord, ClientRequest,
38 FloatingRecord, FloatingRequest, FloatingResponse, KillServerRequest, NodeBreakDestination,
39 NodeJoinPlacement, NodeRequest, PingRequest, ProtocolClient, ServerResponse, SessionRecord,
40 SessionRequest, SessionSnapshot, SnapshotResponse, SynchronizePanesMode, buffer_ids_in_subtree,
41};
42use embers_server::{BUFFER_ENV_VAR, SOCKET_ENV_VAR, Server, ServerConfig};
43use tokio::time::{Duration, sleep};
44use tracing::warn;
45
46const DEFAULT_DISPLAY_MESSAGE: &str = "[#S] #I:#W, current pane #D - (#{pane_current_command})";
48
49const DEFAULT_PANE_FORMAT: &str = "#{session_name}:#{window_index}.#{pane_index}";
51
52const CLIENT_ENV_VAR: &str = "EMBERS_CLIENT";
55
56pub const EMBERS_LOG_ENV_VAR: &str = "EMBERS_LOG";
58
59#[derive(Debug, Parser)]
60#[command(name = "embers", about = "headless terminal multiplexer for embers")]
61pub struct Cli {
62 #[arg(long, global = true)]
63 pub socket: Option<PathBuf>,
64 #[arg(long, global = true)]
65 pub config: Option<PathBuf>,
66 #[arg(
67 long,
68 visible_alias = "log-level",
69 global = true,
70 value_name = "FILTER"
71 )]
72 pub log: Option<String>,
73 #[arg(short = 'v', action = clap::ArgAction::Count)]
76 pub verbose_short: u8,
77 #[arg(long = "verbose", global = true, action = clap::ArgAction::Count, hide = true)]
78 pub verbose: u8,
79 #[arg(long = "base-index", global = true, value_name = "N", value_parser = parse_base_index_arg)]
83 pub base_index: Option<u32>,
84 #[command(subcommand)]
85 pub command: Option<Command>,
86}
87
88impl Cli {
89 pub fn log_filter(&self) -> String {
90 if let Some(filter) = self.log.as_ref().filter(|value| !value.trim().is_empty()) {
91 return filter.clone();
92 }
93 match self.verbose.saturating_add(self.verbose_short) {
94 0 => {}
95 1 => return "debug".to_owned(),
96 _ => return "trace".to_owned(),
97 }
98 if let Some(filter) = std::env::var(EMBERS_LOG_ENV_VAR)
99 .ok()
100 .filter(|value| !value.trim().is_empty())
101 {
102 return filter;
103 }
104 if let Some(filter) = std::env::var("RUST_LOG")
105 .ok()
106 .filter(|value| !value.trim().is_empty())
107 {
108 return filter;
109 }
110 "info".to_owned()
111 }
112
113 pub fn base_index(&self) -> Option<u32> {
115 self.base_index.or_else(embers_client::base_index_from_env)
116 }
117}
118
119fn parse_base_index_arg(value: &str) -> std::result::Result<u32, String> {
120 embers_client::parse_base_index(value).ok_or_else(|| {
121 format!(
122 "expected a number from 0 to {}",
123 embers_client::numbering::MAX_BASE_INDEX
124 )
125 })
126}
127
128#[derive(Debug, Subcommand)]
129pub enum Command {
130 Attach {
131 #[arg(short = 't', long = "target")]
132 target: Option<String>,
133 },
134 #[command(name = "__serve", hide = true)]
135 Serve,
136 #[command(name = "__runtime-keeper", hide = true)]
137 RuntimeKeeper {
138 #[arg(long = "keeper-socket")]
139 keeper_socket: PathBuf,
140 #[arg(long)]
141 cols: u16,
142 #[arg(long)]
143 rows: u16,
144 #[arg(long = "pixel-width", default_value_t = 0)]
145 pixel_width: u16,
146 #[arg(long = "pixel-height", default_value_t = 0)]
147 pixel_height: u16,
148 #[arg(long)]
149 cwd: Option<PathBuf>,
150 #[arg(long = "env", value_parser = parse_env_arg)]
151 env: Vec<(String, OsString)>,
152 #[arg(last = true)]
153 command: Vec<String>,
154 },
155 Automation {
156 #[arg(short = 't', long = "target", conflicts_with = "all_sessions")]
157 target: Option<String>,
158 #[arg(long, conflicts_with = "target")]
159 all_sessions: bool,
160 },
161 Ping {
162 #[arg(default_value = "phase0")]
163 payload: String,
164 },
165 #[command(name = "kill-server")]
169 KillServer,
170 #[command(name = "new-session", visible_alias = "new")]
174 NewSession {
175 #[arg(
177 value_name = "NAME",
178 conflicts_with_all = ["session_name", "attach_existing", "cwd", "window_name", "width", "height", "print", "format", "command"]
179 )]
180 name: Option<String>,
181 #[arg(short = 'd')]
183 detached: bool,
184 #[arg(short = 'A')]
186 attach_existing: bool,
187 #[arg(short = 's', value_name = "NAME")]
188 session_name: Option<String>,
189 #[arg(short = 'n', value_name = "WINDOW")]
191 window_name: Option<String>,
192 #[arg(short = 'c', value_name = "DIR")]
195 cwd: Option<String>,
196 #[arg(short = 'x', value_name = "COLUMNS")]
198 width: Option<u16>,
199 #[arg(short = 'y', value_name = "ROWS")]
200 height: Option<u16>,
201 #[arg(short = 'P')]
203 print: bool,
204 #[arg(short = 'F', value_name = "FORMAT", requires = "print")]
205 format: Option<String>,
206 #[arg(last = true)]
207 command: Vec<String>,
208 },
209 #[command(name = "list-sessions", visible_alias = "ls")]
210 ListSessions {
211 #[arg(short = 'F', value_name = "FORMAT")]
213 format: Option<String>,
214 },
215 #[command(name = "has-session")]
216 HasSession {
217 #[arg(short = 't', long = "target")]
218 target: String,
219 },
220 #[command(name = "kill-session")]
221 KillSession {
223 #[arg(short = 't', long = "target")]
224 target: Option<String>,
225 #[arg(long)]
226 force: bool,
227 #[arg(long)]
229 detach: bool,
230 },
231 #[command(name = "rename-session")]
232 RenameSession {
233 #[arg(short = 't', long = "target")]
234 target: Option<String>,
235 name: String,
236 },
237 #[command(name = "list-buffers")]
238 ListBuffers {
239 #[arg(short = 't', long = "target")]
240 target: Option<String>,
241 #[arg(long, conflicts_with = "detached")]
242 attached: bool,
243 #[arg(long, conflicts_with = "attached")]
244 detached: bool,
245 },
246 #[command(name = "attach-buffer")]
247 AttachBuffer {
248 buffer_id: u64,
249 #[arg(short = 't', long = "target")]
250 target: Option<String>,
251 },
252 #[command(name = "list-clients", visible_alias = "lsc")]
253 ListClients {
254 #[arg(short = 't', long = "target")]
256 target: Option<String>,
257 #[arg(short = 'F', value_name = "FORMAT")]
262 format: Option<String>,
263 },
264 #[command(name = "detach-client", visible_alias = "detach")]
267 DetachClient {
268 #[arg(conflicts_with = "client")]
269 client_id: Option<u64>,
270 #[arg(short = 'c', long = "client", value_name = "CLIENT")]
271 client: Option<u64>,
272 },
273 #[command(name = "switch-client", visible_alias = "switchc")]
280 SwitchClient {
281 #[arg(conflicts_with = "client")]
282 client_id: Option<u64>,
283 #[arg(short = 'c', long = "client", value_name = "CLIENT")]
284 client: Option<u64>,
285 #[arg(
286 short = 't',
287 long = "target",
288 required_unless_present_any = ["next", "previous"],
289 conflicts_with_all = ["next", "previous"]
290 )]
291 target: Option<String>,
292 #[arg(short = 'n', conflicts_with = "previous")]
293 next: bool,
294 #[arg(short = 'p')]
295 previous: bool,
296 },
297 Buffer {
298 #[command(subcommand)]
299 command: BufferCommand,
300 },
301 Node {
302 #[command(subcommand)]
303 command: NodeCommand,
304 },
305 #[command(name = "new-window", visible_alias = "neww")]
310 NewWindow {
311 #[arg(short = 't', long = "target")]
312 target: Option<String>,
313 #[arg(short = 'n', long)]
314 title: Option<String>,
315 #[arg(short = 'c', value_name = "DIR")]
316 cwd: Option<String>,
317 #[arg(short = 'd')]
319 detached: bool,
320 #[arg(short = 'P')]
321 print: bool,
322 #[arg(short = 'F', value_name = "FORMAT", requires = "print")]
323 format: Option<String>,
324 #[arg(last = true)]
325 command: Vec<String>,
326 },
327 #[command(name = "list-windows", visible_alias = "lsw")]
328 ListWindows {
329 #[arg(short = 't', long = "target", conflicts_with = "all")]
330 target: Option<String>,
331 #[arg(short = 'a')]
333 all: bool,
334 #[arg(short = 'F', value_name = "FORMAT")]
337 format: Option<String>,
338 },
339 #[command(name = "select-window")]
340 SelectWindow {
341 #[arg(short = 't', long = "target")]
342 target: String,
343 },
344 #[command(name = "rename-window")]
345 RenameWindow {
346 #[arg(short = 't', long = "target")]
347 target: Option<String>,
348 title: String,
349 },
350 #[command(name = "kill-window")]
351 KillWindow {
353 #[arg(short = 't', long = "target")]
354 target: Option<String>,
355 #[arg(long)]
357 detach: bool,
358 },
359 #[command(
366 name = "split-window",
367 visible_alias = "splitw",
368 disable_help_flag = true
369 )]
370 SplitWindow {
371 #[arg(short = 't', long = "target")]
372 target: Option<String>,
373 #[arg(short = 'h', group = "split_direction")]
374 side_by_side: bool,
375 #[arg(short = 'v', group = "split_direction")]
376 stacked: bool,
377 #[arg(long, group = "split_direction")]
378 horizontal: bool,
379 #[arg(long, group = "split_direction")]
380 vertical: bool,
381 #[arg(short = 'c', value_name = "DIR")]
382 cwd: Option<String>,
383 #[arg(short = 'd')]
385 detached: bool,
386 #[arg(short = 'P')]
387 print: bool,
388 #[arg(short = 'F', value_name = "FORMAT", requires = "print")]
389 format: Option<String>,
390 #[arg(long, action = clap::ArgAction::Help)]
391 help: Option<bool>,
392 #[arg(last = true)]
393 command: Vec<String>,
394 },
395 #[command(name = "list-panes", visible_alias = "lsp")]
396 ListPanes {
397 #[arg(short = 't', long = "target", conflicts_with = "all")]
398 target: Option<String>,
399 #[arg(short = 's', conflicts_with = "all")]
401 session: bool,
402 #[arg(short = 'a')]
404 all: bool,
405 #[arg(short = 'F', value_name = "FORMAT")]
408 format: Option<String>,
409 },
410 #[command(name = "select-pane", visible_alias = "selectp")]
415 SelectPane {
416 #[arg(short = 't', long = "target")]
417 target: Option<String>,
418 #[arg(short = 'l', long = "last", conflicts_with_all = ["target", "pane_direction"])]
419 last: bool,
420 #[arg(short = 'L', group = "pane_direction")]
421 left: bool,
422 #[arg(short = 'R', group = "pane_direction")]
423 right: bool,
424 #[arg(short = 'U', group = "pane_direction")]
425 up: bool,
426 #[arg(short = 'D', group = "pane_direction")]
427 down: bool,
428 },
429 #[command(name = "set-option", visible_alias = "set")]
435 SetOption {
436 #[arg(short = 'p')]
437 pane: bool,
438 #[arg(short = 'w', hide = true)]
439 window: bool,
440 #[arg(short = 's', hide = true)]
441 server: bool,
442 #[arg(short = 'g', hide = true)]
443 global: bool,
444 #[arg(short = 't', long = "target")]
445 target: Option<String>,
446 #[arg(short = 'u')]
447 unset: bool,
448 #[arg(short = 'o')]
449 only_if_unset: bool,
450 #[arg(short = 'q')]
451 quiet: bool,
452 name: String,
453 #[arg(conflicts_with = "unset")]
454 value: Option<String>,
455 },
456 #[command(name = "show-options", visible_alias = "show")]
460 ShowOptions {
461 #[arg(short = 'p')]
462 pane: bool,
463 #[arg(short = 'w', hide = true)]
464 window: bool,
465 #[arg(short = 's', hide = true)]
466 server: bool,
467 #[arg(short = 'g', hide = true)]
468 global: bool,
469 #[arg(short = 't', long = "target")]
470 target: Option<String>,
471 #[arg(short = 'v')]
472 value_only: bool,
473 #[arg(short = 'q')]
474 quiet: bool,
475 name: Option<String>,
476 },
477 #[command(name = "resize-pane")]
481 ResizePane {
482 #[arg(short = 't', long = "target")]
483 target: Option<String>,
484 #[arg(long, value_delimiter = ',', conflicts_with_all = ["direction", "zoom"])]
485 sizes: Vec<u16>,
486 #[arg(short = 'L', group = "direction")]
487 left: bool,
488 #[arg(short = 'R', group = "direction")]
489 right: bool,
490 #[arg(short = 'U', group = "direction")]
491 up: bool,
492 #[arg(short = 'D', group = "direction")]
493 down: bool,
494 #[arg(short = 'Z', conflicts_with = "direction")]
495 zoom: bool,
496 #[arg(requires = "direction")]
497 adjustment: Option<u16>,
498 },
499 #[command(name = "synchronize-panes")]
502 SynchronizePanes {
503 #[arg(short = 't', long = "target")]
504 target: Option<String>,
505 state: Option<SwitchArg>,
506 },
507 #[command(name = "send-keys")]
508 SendKeys {
509 #[arg(short = 't', long = "target")]
510 target: Option<String>,
511 #[arg(long)]
512 enter: bool,
513 keys: Vec<String>,
514 },
515 #[command(name = "capture-pane", visible_alias = "capturep")]
522 CapturePane {
523 #[arg(short = 't', long = "target")]
524 target: Option<String>,
525 #[arg(short = 'p')]
526 print: bool,
527 #[arg(short = 'S', value_name = "LINE", allow_hyphen_values = true, value_parser = capture::parse_line_bound)]
528 start: Option<capture::LineBound>,
529 #[arg(short = 'E', value_name = "LINE", allow_hyphen_values = true, value_parser = capture::parse_line_bound)]
530 end: Option<capture::LineBound>,
531 #[arg(short = 'J')]
532 join: bool,
533 #[arg(short = 'e')]
534 escapes: bool,
535 },
536 #[command(name = "kill-pane")]
537 KillPane {
539 #[arg(short = 't', long = "target")]
540 target: Option<String>,
541 #[arg(long)]
543 detach: bool,
544 },
545 #[command(
552 name = "display-popup",
553 visible_alias = "popup",
554 disable_help_flag = true
555 )]
556 DisplayPopup {
557 #[arg(short = 't', long = "target")]
558 target: Option<String>,
559 #[arg(short = 'T', long)]
561 title: Option<String>,
562 #[arg(short = 'x', long = "x", value_parser = popup::parse_popup_position)]
563 x: Option<popup::PopupPosition>,
564 #[arg(short = 'y', long = "y", value_parser = popup::parse_popup_position)]
565 y: Option<popup::PopupPosition>,
566 #[arg(short = 'w', long = "width", value_parser = popup::parse_popup_length)]
567 width: Option<popup::PopupLength>,
568 #[arg(short = 'h', long = "height", value_parser = popup::parse_popup_length)]
569 height: Option<popup::PopupLength>,
570 #[arg(short = 'E', action = clap::ArgAction::Count)]
573 close_on_exit: u8,
574 #[arg(short = 'd', short_alias = 'c', long = "cwd", value_name = "DIR")]
578 cwd: Option<String>,
579 #[arg(short = 'P')]
582 print: bool,
583 #[arg(short = 'F', value_name = "FORMAT", requires = "print")]
584 format: Option<String>,
585 #[arg(long)]
587 no_wait: bool,
588 #[arg(short = 'B', long = "no-border")]
590 no_border: bool,
591 #[arg(long, action = clap::ArgAction::Help)]
592 help: Option<bool>,
593 #[arg(last = true)]
594 command: Vec<String>,
595 },
596 #[command(name = "kill-popup")]
597 KillPopup {
599 #[arg(short = 't', long = "target")]
600 target: Option<String>,
601 #[arg(long)]
603 detach: bool,
604 },
605 #[command(name = "display-message", visible_alias = "display")]
610 DisplayMessage {
611 #[arg(short = 't', long = "target")]
612 target: Option<String>,
613 #[arg(short = 'c', long = "client")]
614 client: Option<NonZeroU64>,
615 #[arg(short = 'p', long = "print")]
616 print: bool,
617 #[arg(short = 'd', long = "duration", value_name = "MS")]
619 duration: Option<u32>,
620 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
621 message: Vec<String>,
622 },
623 #[command(name = "set-environment", visible_alias = "setenv")]
626 SetEnvironment {
627 #[arg(short = 't', long = "target", conflicts_with = "global")]
628 target: Option<String>,
629 #[arg(short = 'g', long = "global")]
630 global: bool,
631 #[arg(short = 'u', long = "unset")]
632 unset: bool,
633 name: String,
634 #[arg(conflicts_with = "unset", allow_hyphen_values = true)]
635 value: Option<String>,
636 },
637 #[command(name = "show-environment", visible_alias = "showenv")]
640 ShowEnvironment {
641 #[arg(short = 't', long = "target", conflicts_with = "global")]
642 target: Option<String>,
643 #[arg(short = 'g', long = "global")]
644 global: bool,
645 name: Option<String>,
646 },
647}
648
649#[derive(Debug, Subcommand)]
650pub enum BufferCommand {
651 Show {
652 buffer_id: u64,
653 },
654 Pipe {
655 #[command(subcommand)]
656 command: BufferPipeCommand,
657 },
658 Reveal {
659 buffer_id: u64,
660 #[arg(long)]
661 client: Option<NonZeroU64>,
662 },
663 History {
664 buffer_id: u64,
665 #[arg(long, default_value = "full")]
666 scope: HistoryScopeArg,
667 #[arg(long, default_value = "tab")]
668 placement: HistoryPlacementArg,
669 #[arg(long)]
670 client: Option<NonZeroU64>,
671 },
672 SetOption {
673 #[arg(short = 't', long = "target")]
674 target: Option<String>,
675 key: String,
676 value: Option<String>,
677 #[arg(long, conflicts_with = "value")]
678 unset: bool,
679 },
680 ShowOptions {
681 #[arg(short = 't', long = "target")]
682 target: Option<String>,
683 },
684}
685
686#[derive(Debug, Subcommand)]
687pub enum BufferPipeCommand {
688 Start {
689 buffer_id: u64,
690 #[arg(long)]
691 cwd: Option<String>,
692 #[arg(long = "env", value_parser = parse_string_env_arg)]
693 env: Vec<(String, String)>,
694 #[arg(last = true)]
695 command: Vec<String>,
696 },
697 Stop {
698 buffer_id: u64,
699 },
700 Show {
701 buffer_id: u64,
702 },
703}
704
705#[derive(Debug, Subcommand)]
706pub enum NodeCommand {
707 Zoom {
708 node_id: u64,
709 },
710 Unzoom {
711 #[arg(short = 't', long = "target")]
712 target: Option<String>,
713 },
714 ToggleZoom {
715 node_id: u64,
716 },
717 Swap {
718 first_node_id: u64,
719 second_node_id: u64,
720 },
721 Break {
722 node_id: u64,
723 #[arg(long = "to")]
724 destination: BreakDestinationArg,
725 },
726 JoinBuffer {
727 node_id: u64,
728 buffer_id: u64,
729 #[arg(long = "as", default_value = "tab-after")]
730 placement: JoinPlacementArg,
731 },
732 MoveBefore {
733 node_id: u64,
734 sibling_id: u64,
735 },
736 MoveAfter {
737 node_id: u64,
738 sibling_id: u64,
739 },
740}
741
742#[derive(Clone, Copy, Debug, clap::ValueEnum)]
743pub enum SwitchArg {
744 On,
745 Off,
746}
747
748#[derive(Clone, Copy, Debug, clap::ValueEnum)]
749pub enum HistoryScopeArg {
750 Full,
751 Visible,
752}
753
754#[derive(Clone, Copy, Debug, clap::ValueEnum)]
755pub enum HistoryPlacementArg {
756 Tab,
757 Floating,
758}
759
760#[derive(Clone, Copy, Debug, clap::ValueEnum)]
761pub enum BreakDestinationArg {
762 Tab,
763 Floating,
764}
765
766#[derive(Clone, Copy, Debug, clap::ValueEnum)]
767pub enum JoinPlacementArg {
768 Left,
769 Right,
770 Up,
771 Down,
772 #[value(name = "tab-before")]
773 TabBefore,
774 #[value(name = "tab-after")]
775 TabAfter,
776}
777
778struct Outcome {
780 output: String,
781 attach_session: Option<String>,
783 exit_status: Option<i32>,
785}
786
787async fn execute(socket: &Path, command: Command, base_index: Option<u32>) -> Result<Outcome> {
788 use std::io::IsTerminal as _;
789
790 let mut connection = CliConnection::connect(socket).await?;
791 connection.base_index = base_index;
792 let pane_socket = std::env::var_os(SOCKET_ENV_VAR);
793 connection.pane_buffer = pane_buffer(
794 std::env::var(BUFFER_ENV_VAR).ok().as_deref(),
795 pane_socket.as_deref(),
796 socket,
797 );
798 connection.caller_client = pane_buffer(
799 std::env::var(CLIENT_ENV_VAR).ok().as_deref(),
800 pane_socket.as_deref(),
801 socket,
802 )
803 .and_then(|client| NonZeroU64::new(client.0));
804 connection.working_dir = std::env::current_dir()
805 .ok()
806 .map(|dir| dir.to_string_lossy().into_owned());
807 connection.wait_for_popups = true;
808 connection.can_attach = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
809 let output = execute_command(&mut connection, command).await?;
810 Ok(Outcome {
811 output,
812 attach_session: connection.attach_session,
813 exit_status: connection.exit_status,
814 })
815}
816
817async fn execute_command(connection: &mut CliConnection, command: Command) -> Result<String> {
818 match command {
819 Command::Attach { .. }
820 | Command::Serve
821 | Command::RuntimeKeeper { .. }
822 | Command::Automation { .. } => Err(MuxError::invalid_input(
823 "interactive commands must be dispatched through run()",
824 )),
825 Command::Ping { payload } => {
826 let response = connection
827 .request(ClientMessage::Ping(PingRequest {
828 request_id: new_request_id(),
829 payload,
830 }))
831 .await?;
832 match response {
833 ServerResponse::Pong(response) => Ok(format!("pong {}", response.payload)),
834 other => Err(MuxError::protocol(format!(
835 "unexpected response to ping request: {other:?}"
836 ))),
837 }
838 }
839 Command::KillServer => {
840 connection
841 .request(ClientMessage::KillServer(KillServerRequest {
842 request_id: new_request_id(),
843 }))
844 .await?;
845 Ok(String::new())
846 }
847 Command::NewSession {
849 name: Some(name), ..
850 } => {
851 let response = connection
852 .request(ClientMessage::Session(SessionRequest::Create {
853 request_id: new_request_id(),
854 name,
855 }))
856 .await?;
857 let snapshot = expect_session_snapshot(response, "new-session")?;
858 Ok(format!(
859 "{}\t{}",
860 snapshot.session.id, snapshot.session.name
861 ))
862 }
863 Command::NewSession {
864 name: None,
865 detached,
866 attach_existing,
867 session_name,
868 window_name,
869 cwd,
870 width,
871 height,
872 print,
873 format,
874 command,
875 } => {
876 connection
877 .new_session(sessions::NewSession {
878 detached,
879 attach_existing,
880 name: session_name,
881 window_name,
882 cwd,
883 width,
884 height,
885 format: print.then(|| format.unwrap_or_else(|| "#{session_name}:".to_owned())),
886 command,
887 })
888 .await
889 }
890 Command::ListSessions { format } => {
891 let sessions = connection.list_sessions().await?;
892 let Some(format) = format else {
893 return Ok(format_sessions(&sessions));
894 };
895 let mut lines = Vec::new();
896 for session in sessions {
897 let snapshot = connection.session_snapshot(session.id).await?;
898 lines.push(connection.expand_for_pane(&snapshot, None, &format).await?);
899 }
900 Ok(lines.join("\n"))
901 }
902 Command::HasSession { target } => {
903 connection.resolve_session_record(Some(&target)).await?;
904 Ok(String::new())
905 }
906 Command::KillSession {
907 target,
908 force,
909 detach,
910 } => {
911 let snapshot = connection
912 .resolve_session_snapshot(target.as_deref())
913 .await?;
914 let shown = snapshot
915 .buffers
916 .iter()
917 .map(|buffer| buffer.id)
918 .collect::<Vec<_>>();
919 connection
920 .request(ClientMessage::Session(SessionRequest::Close {
921 request_id: new_request_id(),
922 session_id: snapshot.session.id,
923 force,
924 }))
925 .await?;
926 if !detach {
927 connection.kill_detached_buffers(shown).await?;
928 }
929 Ok(String::new())
930 }
931 Command::RenameSession { target, name } => {
932 let session = connection.resolve_session_record(target.as_deref()).await?;
933 connection
934 .request(ClientMessage::Session(SessionRequest::Rename {
935 request_id: new_request_id(),
936 session_id: session.id,
937 name,
938 }))
939 .await?;
940 Ok(String::new())
941 }
942 Command::ListBuffers {
943 target,
944 attached,
945 detached,
946 } => {
947 let session_id = match target {
948 Some(target) => Some(connection.resolve_session_record(Some(&target)).await?.id),
949 None => None,
950 };
951 let response = connection
952 .request(ClientMessage::Buffer(BufferRequest::List {
953 request_id: new_request_id(),
954 session_id,
955 attached_only: attached,
956 detached_only: detached,
957 }))
958 .await?;
959 match response {
960 ServerResponse::Buffers(response) => Ok(format_buffers(&response.buffers)),
961 other => Err(MuxError::protocol(format!(
962 "unexpected response to list-buffers: {other:?}"
963 ))),
964 }
965 }
966 Command::AttachBuffer { buffer_id, target } => {
967 let pane = connection.resolve_pane(target.as_deref()).await?;
968 let response = connection
969 .request(ClientMessage::Node(NodeRequest::MoveBufferToNode {
970 request_id: new_request_id(),
971 buffer_id: BufferId(buffer_id),
972 target_leaf_node_id: pane.leaf_id,
973 }))
974 .await?;
975 expect_session_snapshot(response, "attach-buffer")?;
976 Ok(String::new())
977 }
978 Command::ListClients { target, format } => {
979 let sessions = connection.list_sessions().await?;
980 let mut clients = connection.list_clients().await?;
981 if let Some(target) = target {
982 let session = connection.resolve_session_record(Some(&target)).await?;
983 clients.retain(|client| client.current_session_id == Some(session.id));
984 }
985 let Some(format) = format else {
986 return Ok(format_clients(&clients, &sessions));
987 };
988 let mut lines = Vec::new();
989 for client in clients {
990 let session_name = client
991 .current_session_id
992 .and_then(|id| sessions.iter().find(|session| session.id == id))
993 .map(|session| session.name.clone())
994 .unwrap_or_default();
995 let extra = std::collections::BTreeMap::from([
996 ("client_name".to_owned(), client.id.to_string()),
997 ("client_id".to_owned(), client.id.to_string()),
998 ("client_session".to_owned(), session_name),
999 ]);
1000 let line = match client.current_session_id {
1001 Some(session_id) => {
1002 let snapshot = connection.session_snapshot(session_id).await?;
1003 connection
1004 .expand_for_pane_with(&snapshot, None, &format, extra)
1005 .await?
1006 }
1007 None => embers_client::expand_format(
1008 &format,
1009 &embers_client::FormatContext {
1010 extra,
1011 ..embers_client::FormatContext::default()
1012 },
1013 ),
1014 };
1015 lines.push(line);
1016 }
1017 Ok(lines.join("\n"))
1018 }
1019 Command::DetachClient { client_id, client } => {
1020 let client_id = connection.target_client(client_id.or(client)).await?;
1021 connection
1022 .request(ClientMessage::Client(ClientRequest::Detach {
1023 request_id: new_request_id(),
1024 client_id: Some(client_id),
1025 }))
1026 .await?;
1027 Ok(String::new())
1028 }
1029 Command::SwitchClient {
1030 client_id,
1031 client,
1032 target,
1033 next: _,
1034 previous,
1035 } => {
1036 let client_id = connection.target_client(client_id.or(client)).await?;
1037 let (session_id, node_id) = match target {
1038 Some(target) => connection.switch_target(&target).await?,
1039 None => (
1040 connection.neighbour_session(client_id, !previous).await?.id,
1041 None,
1042 ),
1043 };
1044 match connection
1045 .request(ClientMessage::Client(ClientRequest::Switch {
1046 request_id: new_request_id(),
1047 client_id: Some(client_id),
1048 session_id,
1049 node_id,
1050 }))
1051 .await?
1052 {
1053 ServerResponse::Client(_) => Ok(String::new()),
1054 other => Err(MuxError::protocol(format!(
1055 "unexpected response to switch-client: {other:?}"
1056 ))),
1057 }
1058 }
1059 Command::Buffer { command } => match command {
1060 BufferCommand::Show { buffer_id } => {
1061 let requested_buffer_id = BufferId(buffer_id);
1062 let response = connection
1063 .request(ClientMessage::Buffer(BufferRequest::Inspect {
1064 request_id: new_request_id(),
1065 buffer_id: requested_buffer_id,
1066 }))
1067 .await?;
1068 let (buffer, location, _) = expect_buffer_with_location(response, "buffer show")?;
1069 ensure_matching_buffer_id("buffer show", requested_buffer_id, buffer.id)?;
1070 Ok(format_buffer_details(&buffer, &location))
1071 }
1072 BufferCommand::Pipe { command } => match command {
1073 BufferPipeCommand::Start {
1074 buffer_id,
1075 cwd,
1076 env,
1077 command,
1078 } => {
1079 if command.is_empty() {
1080 return Err(MuxError::invalid_input(
1081 "buffer pipe command must not be empty",
1082 ));
1083 }
1084 let expected = BufferId(buffer_id);
1085 let response = connection
1086 .request(ClientMessage::Buffer(BufferRequest::StartPipe {
1087 request_id: new_request_id(),
1088 buffer_id: expected,
1089 command,
1090 cwd,
1091 env: env.into_iter().collect(),
1092 }))
1093 .await?;
1094 match response {
1095 ServerResponse::Buffer(response) => {
1096 ensure_matching_buffer_id(
1097 "buffer pipe start",
1098 expected,
1099 response.buffer.id,
1100 )?;
1101 format_buffer_pipe_details(
1102 response.buffer.id,
1103 response.buffer.pipe.as_ref(),
1104 )
1105 }
1106 other => Err(MuxError::protocol(format!(
1107 "unexpected response to buffer pipe start: {other:?}"
1108 ))),
1109 }
1110 }
1111 BufferPipeCommand::Stop { buffer_id } => {
1112 let expected = BufferId(buffer_id);
1113 let response = connection
1114 .request(ClientMessage::Buffer(BufferRequest::StopPipe {
1115 request_id: new_request_id(),
1116 buffer_id: expected,
1117 }))
1118 .await?;
1119 match response {
1120 ServerResponse::Buffer(response) => {
1121 ensure_matching_buffer_id(
1122 "buffer pipe stop",
1123 expected,
1124 response.buffer.id,
1125 )?;
1126 format_buffer_pipe_details(
1127 response.buffer.id,
1128 response.buffer.pipe.as_ref(),
1129 )
1130 }
1131 other => Err(MuxError::protocol(format!(
1132 "unexpected response to buffer pipe stop: {other:?}"
1133 ))),
1134 }
1135 }
1136 BufferPipeCommand::Show { buffer_id } => {
1137 let expected = BufferId(buffer_id);
1138 let response = connection
1139 .request(ClientMessage::Buffer(BufferRequest::Get {
1140 request_id: new_request_id(),
1141 buffer_id: expected,
1142 }))
1143 .await?;
1144 match response {
1145 ServerResponse::Buffer(response) => {
1146 ensure_matching_buffer_id(
1147 "buffer pipe show",
1148 expected,
1149 response.buffer.id,
1150 )?;
1151 format_buffer_pipe_details(
1152 response.buffer.id,
1153 response.buffer.pipe.as_ref(),
1154 )
1155 }
1156 other => Err(MuxError::protocol(format!(
1157 "unexpected response to buffer pipe show: {other:?}"
1158 ))),
1159 }
1160 }
1161 },
1162 BufferCommand::Reveal { buffer_id, client } => {
1163 let requested_buffer_id = BufferId(buffer_id);
1164 let response = connection
1165 .request(ClientMessage::Buffer(BufferRequest::Reveal {
1166 request_id: new_request_id(),
1167 buffer_id: requested_buffer_id,
1168 client_id: client,
1169 }))
1170 .await?;
1171 let location = expect_buffer_location(response, "buffer reveal")?;
1172 ensure_matching_buffer_id(
1173 "buffer reveal",
1174 requested_buffer_id,
1175 location.buffer_id,
1176 )?;
1177 if matches!(location.attachment, BufferLocationAttachment::Detached) {
1178 return Err(MuxError::conflict(format!(
1179 "buffer {} is detached; use attach-buffer or node join-buffer",
1180 buffer_id
1181 )));
1182 }
1183 Ok(format_buffer_location_line(&location))
1184 }
1185 BufferCommand::History {
1186 buffer_id,
1187 scope,
1188 placement,
1189 client,
1190 } => {
1191 let requested_scope = history_scope(scope);
1192 let requested_placement = history_placement(placement);
1193 let response = connection
1194 .request(ClientMessage::Buffer(BufferRequest::OpenHistory {
1195 request_id: new_request_id(),
1196 buffer_id: BufferId(buffer_id),
1197 scope: requested_scope,
1198 placement: requested_placement,
1199 client_id: client,
1200 }))
1201 .await?;
1202 let (buffer, location, at_root_tab) =
1203 expect_buffer_with_location(response, "buffer history")?;
1204 ensure_history_response(
1205 BufferId(buffer_id),
1206 requested_scope,
1207 requested_placement,
1208 &buffer,
1209 &location,
1210 at_root_tab,
1211 )?;
1212 Ok(format_buffer_location_line(&location))
1213 }
1214 BufferCommand::SetOption {
1215 target,
1216 key,
1217 value,
1218 unset,
1219 } => {
1220 let buffer_id = connection.resolve_buffer(target.as_deref()).await?;
1221 let value = if unset {
1222 None
1223 } else {
1224 Some(value.ok_or_else(|| {
1225 MuxError::invalid_input(
1226 "buffer set-option requires a value (or --unset to clear it)",
1227 )
1228 })?)
1229 };
1230 connection
1231 .set_buffer_option(buffer_id, key, value, "buffer set-option")
1232 .await?;
1233 Ok(String::new())
1234 }
1235 BufferCommand::ShowOptions { target } => {
1236 let buffer_id = connection.resolve_buffer(target.as_deref()).await?;
1237 Ok(connection
1238 .buffer_options(buffer_id, "buffer show-options")
1239 .await?
1240 .iter()
1241 .map(|(key, value)| {
1242 format!(
1247 "{}\t{}",
1248 serde_json::to_string(key).expect("user option keys serialize to JSON"),
1249 serde_json::to_string(value)
1250 .expect("user option values serialize to JSON"),
1251 )
1252 })
1253 .collect::<Vec<_>>()
1254 .join("\n"))
1255 }
1256 },
1257 Command::Node { command } => match command {
1258 NodeCommand::Zoom { node_id } => {
1259 let response = connection
1260 .request(ClientMessage::Node(NodeRequest::Zoom {
1261 request_id: new_request_id(),
1262 node_id: NodeId(node_id),
1263 }))
1264 .await?;
1265 expect_ok(response, "NodeCommand::Zoom")?;
1266 Ok(String::new())
1267 }
1268 NodeCommand::Unzoom { target } => {
1269 let session = connection.resolve_session_record(target.as_deref()).await?;
1270 let response = connection
1271 .request(ClientMessage::Node(NodeRequest::Unzoom {
1272 request_id: new_request_id(),
1273 session_id: session.id,
1274 }))
1275 .await?;
1276 expect_ok(response, "NodeCommand::Unzoom")?;
1277 Ok(String::new())
1278 }
1279 NodeCommand::ToggleZoom { node_id } => {
1280 let response = connection
1281 .request(ClientMessage::Node(NodeRequest::ToggleZoom {
1282 request_id: new_request_id(),
1283 node_id: NodeId(node_id),
1284 }))
1285 .await?;
1286 expect_ok(response, "NodeCommand::ToggleZoom")?;
1287 Ok(String::new())
1288 }
1289 NodeCommand::Swap {
1290 first_node_id,
1291 second_node_id,
1292 } => {
1293 let response = connection
1294 .request(ClientMessage::Node(NodeRequest::SwapSiblings {
1295 request_id: new_request_id(),
1296 first_node_id: NodeId(first_node_id),
1297 second_node_id: NodeId(second_node_id),
1298 }))
1299 .await?;
1300 expect_ok(response, "NodeCommand::Swap")?;
1301 Ok(String::new())
1302 }
1303 NodeCommand::Break {
1304 node_id,
1305 destination,
1306 } => {
1307 let response = connection
1308 .request(ClientMessage::Node(NodeRequest::BreakNode {
1309 request_id: new_request_id(),
1310 node_id: NodeId(node_id),
1311 destination: break_destination(destination),
1312 }))
1313 .await?;
1314 expect_ok(response, "NodeCommand::Break")?;
1315 Ok(String::new())
1316 }
1317 NodeCommand::JoinBuffer {
1318 node_id,
1319 buffer_id,
1320 placement,
1321 } => {
1322 let response = connection
1323 .request(ClientMessage::Node(NodeRequest::JoinBufferAtNode {
1324 request_id: new_request_id(),
1325 node_id: NodeId(node_id),
1326 buffer_id: BufferId(buffer_id),
1327 placement: join_placement(placement),
1328 }))
1329 .await?;
1330 expect_ok(response, "NodeCommand::JoinBuffer")?;
1331 Ok(String::new())
1332 }
1333 NodeCommand::MoveBefore {
1334 node_id,
1335 sibling_id,
1336 } => {
1337 let response = connection
1338 .request(ClientMessage::Node(NodeRequest::MoveNodeBefore {
1339 request_id: new_request_id(),
1340 node_id: NodeId(node_id),
1341 sibling_node_id: NodeId(sibling_id),
1342 }))
1343 .await?;
1344 expect_ok(response, "NodeCommand::MoveBefore")?;
1345 Ok(String::new())
1346 }
1347 NodeCommand::MoveAfter {
1348 node_id,
1349 sibling_id,
1350 } => {
1351 let response = connection
1352 .request(ClientMessage::Node(NodeRequest::MoveNodeAfter {
1353 request_id: new_request_id(),
1354 node_id: NodeId(node_id),
1355 sibling_node_id: NodeId(sibling_id),
1356 }))
1357 .await?;
1358 expect_ok(response, "NodeCommand::MoveAfter")?;
1359 Ok(String::new())
1360 }
1361 },
1362 Command::NewWindow {
1363 target,
1364 title,
1365 cwd,
1366 detached,
1367 print,
1368 format,
1369 command,
1370 } => {
1371 let session = connection.resolve_session_record(target.as_deref()).await?;
1372 let command = buffer_command(command);
1373 let window_title = title.unwrap_or_else(|| default_title(&command, "window"));
1374 let snapshot = connection.session_snapshot(session.id).await?;
1375 let cwd = connection
1376 .spawn_cwd(cwd.as_deref(), Some(&snapshot))
1377 .await?;
1378 let previous_window = current_root_window(&snapshot)?;
1379 let previous_focus = snapshot.session.focused_leaf_id;
1380 let size = spawn_size(&snapshot, PanePlacement::RootTab);
1381 let buffer = connection
1382 .create_buffer(Some(window_title.clone()), command, cwd, size, session.id)
1383 .await?;
1384 let response = connection
1385 .request(ClientMessage::Session(SessionRequest::AddRootTab {
1386 request_id: new_request_id(),
1387 session_id: session.id,
1388 title: window_title.clone(),
1389 buffer_id: Some(buffer.buffer.id),
1390 child_node_id: None,
1391 }))
1392 .await;
1393 let response = rollback_created_buffer_on_error(
1394 connection,
1395 buffer.buffer.id,
1396 "new-window",
1397 response,
1398 )
1399 .await?;
1400 let mut snapshot = rollback_created_buffer_on_error(
1401 connection,
1402 buffer.buffer.id,
1403 "new-window",
1404 expect_session_snapshot(response, "new-window"),
1405 )
1406 .await?;
1407 let (index, title) = active_root_window(&snapshot)?;
1408 let number = window_number(index as usize, connection.base_index_for(&snapshot));
1409 if detached {
1410 snapshot = restore_current(
1411 connection,
1412 snapshot,
1413 previous_window,
1414 previous_focus,
1415 "new-window",
1416 )
1417 .await?;
1418 }
1419 if print {
1420 let pane = pane_showing(&snapshot, buffer.buffer.id);
1421 let format = format.unwrap_or_else(|| DEFAULT_PANE_FORMAT.to_owned());
1422 return connection.expand_for_pane(&snapshot, pane, &format).await;
1423 }
1424 Ok(format!("{number}\t{title}"))
1425 }
1426 Command::ListWindows {
1427 target,
1428 all,
1429 format,
1430 } => {
1431 let snapshots = if all {
1432 let mut snapshots = Vec::new();
1433 for session in connection.list_sessions().await? {
1434 snapshots.push(connection.session_snapshot(session.id).await?);
1435 }
1436 snapshots
1437 } else {
1438 vec![
1439 connection
1440 .resolve_session_snapshot(target.as_deref())
1441 .await?,
1442 ]
1443 };
1444 let mut lines = Vec::new();
1445 for snapshot in snapshots {
1446 match &format {
1447 None => {
1448 let base_index = connection.base_index_for(&snapshot);
1449 lines.push(format_windows(&snapshot, base_index)?);
1450 }
1451 Some(format) => {
1452 for pane in window_panes(&snapshot)? {
1453 lines.push(connection.expand_for_pane(&snapshot, pane, format).await?);
1454 }
1455 }
1456 }
1457 }
1458 Ok(lines.join("\n"))
1459 }
1460 Command::SelectWindow { target } => {
1461 let window = connection.resolve_window(Some(&target)).await?;
1462 connection
1463 .request(ClientMessage::Session(SessionRequest::SelectRootTab {
1464 request_id: new_request_id(),
1465 session_id: window.snapshot.session.id,
1466 index: window.index,
1467 }))
1468 .await?;
1469 Ok(String::new())
1470 }
1471 Command::RenameWindow { target, title } => {
1472 let window = connection.resolve_window(target.as_deref()).await?;
1473 connection
1474 .request(ClientMessage::Session(SessionRequest::RenameRootTab {
1475 request_id: new_request_id(),
1476 session_id: window.snapshot.session.id,
1477 index: window.index,
1478 title,
1479 }))
1480 .await?;
1481 Ok(String::new())
1482 }
1483 Command::SynchronizePanes { target, state } => {
1484 let window = connection.resolve_window(target.as_deref()).await?;
1485 let response = connection
1486 .request(ClientMessage::Session(
1487 SessionRequest::SetRootTabSynchronizePanes {
1488 request_id: new_request_id(),
1489 session_id: window.snapshot.session.id,
1490 index: window.index,
1491 mode: match state {
1492 None => SynchronizePanesMode::Toggle,
1493 Some(SwitchArg::On) => SynchronizePanesMode::On,
1494 Some(SwitchArg::Off) => SynchronizePanesMode::Off,
1495 },
1496 },
1497 ))
1498 .await?;
1499 let snapshot = expect_session_snapshot(response, "synchronize-panes")?;
1500 let (_, tabs) = root_tabs(&snapshot)?;
1501 let enabled = protocol_tab(tabs, window.index).is_some_and(|tab| tab.synchronize_panes);
1502 Ok(if enabled { "on" } else { "off" }.to_owned())
1503 }
1504 Command::KillWindow { target, detach } => {
1505 let window = connection.resolve_window(target.as_deref()).await?;
1506 let shown = buffer_ids_in_subtree(&window.snapshot.nodes, window.child_id);
1507 connection
1508 .request(ClientMessage::Session(SessionRequest::CloseRootTab {
1509 request_id: new_request_id(),
1510 session_id: window.snapshot.session.id,
1511 index: window.index,
1512 }))
1513 .await?;
1514 if !detach {
1515 connection.kill_detached_buffers(shown).await?;
1516 }
1517 Ok(String::new())
1518 }
1519 Command::SplitWindow {
1520 target,
1521 side_by_side: _,
1522 stacked,
1523 horizontal,
1524 vertical: _,
1525 cwd,
1526 detached,
1527 print,
1528 format,
1529 help: _,
1530 command,
1531 } => {
1532 let pane = connection.resolve_pane(target.as_deref()).await?;
1533 let previous_window = current_root_window(&pane.snapshot)?;
1534 let previous_focus = pane.snapshot.session.focused_leaf_id;
1535 let command = buffer_command(command);
1536 let direction = if horizontal || stacked {
1537 SplitDirection::Horizontal
1538 } else {
1539 SplitDirection::Vertical
1540 };
1541 let cwd = connection
1542 .spawn_cwd_for_pane(cwd.as_deref(), &pane.snapshot, pane.leaf_id)
1543 .await?;
1544 let size = spawn_size(
1545 &pane.snapshot,
1546 PanePlacement::Split {
1547 leaf_id: pane.leaf_id,
1548 direction,
1549 },
1550 );
1551 let buffer = connection
1552 .create_buffer(
1553 Some(default_title(&command, "pane")),
1554 command,
1555 cwd,
1556 size,
1557 pane.snapshot.session.id,
1558 )
1559 .await?;
1560 let response = connection
1561 .request(ClientMessage::Node(NodeRequest::Split {
1562 request_id: new_request_id(),
1563 leaf_node_id: pane.leaf_id,
1564 direction,
1565 new_buffer_id: buffer.buffer.id,
1566 }))
1567 .await;
1568 let response = rollback_created_buffer_on_error(
1569 connection,
1570 buffer.buffer.id,
1571 "split-window",
1572 response,
1573 )
1574 .await?;
1575 let mut snapshot = rollback_created_buffer_on_error(
1576 connection,
1577 buffer.buffer.id,
1578 "split-window",
1579 expect_session_snapshot(response, "split-window"),
1580 )
1581 .await?;
1582 let new_pane = pane_showing(&snapshot, buffer.buffer.id).ok_or_else(|| {
1583 MuxError::protocol("split-window response does not show the new pane")
1584 })?;
1585 if detached {
1586 let response = connection
1591 .request(ClientMessage::Node(NodeRequest::SelectPane {
1592 request_id: new_request_id(),
1593 session_id: snapshot.session.id,
1594 node_id: pane.leaf_id,
1595 }))
1596 .await?;
1597 snapshot = expect_session_snapshot(response, "split-window")?;
1598 snapshot = restore_current(
1599 connection,
1600 snapshot,
1601 previous_window,
1602 previous_focus,
1603 "split-window",
1604 )
1605 .await?;
1606 }
1607 if print {
1608 let format = format.unwrap_or_else(|| DEFAULT_PANE_FORMAT.to_owned());
1609 return connection
1610 .expand_for_pane(&snapshot, Some(new_pane), &format)
1611 .await;
1612 }
1613 Ok(new_pane.to_string())
1614 }
1615 Command::ListPanes {
1616 target,
1617 session,
1618 all,
1619 format,
1620 } => {
1621 let mut panes = Vec::new();
1622 if all || session {
1623 let snapshots = if all {
1624 let mut snapshots = Vec::new();
1625 for session in connection.list_sessions().await? {
1626 snapshots.push(connection.session_snapshot(session.id).await?);
1627 }
1628 snapshots
1629 } else {
1630 vec![
1631 connection
1632 .resolve_session_snapshot(target.as_deref())
1633 .await?,
1634 ]
1635 };
1636 for snapshot in snapshots {
1637 let mut leaf_ids = Vec::new();
1638 for window in window_children(&snapshot)? {
1639 leaf_ids.extend(visible_leaf_ids(&snapshot, window)?);
1640 }
1641 panes.push((snapshot, leaf_ids));
1642 }
1643 } else {
1644 let window = connection.resolve_window(target.as_deref()).await?;
1645 let leaf_ids = visible_leaf_ids(&window.snapshot, window.child_id)?;
1646 panes.push((window.snapshot, leaf_ids));
1647 }
1648 let mut lines = Vec::new();
1649 for (snapshot, leaf_ids) in panes {
1650 match &format {
1651 None => lines.push(format_panes(&snapshot, &leaf_ids)?),
1652 Some(format) => {
1653 for leaf_id in leaf_ids {
1654 lines.push(
1655 connection
1656 .expand_for_pane(&snapshot, Some(leaf_id), format)
1657 .await?,
1658 );
1659 }
1660 }
1661 }
1662 }
1663 Ok(lines.join("\n"))
1664 }
1665 Command::SelectPane {
1666 target, last: true, ..
1667 } => {
1668 let session = connection.resolve_session_record(target.as_deref()).await?;
1669 connection
1670 .request(ClientMessage::Node(NodeRequest::FocusLast {
1671 request_id: new_request_id(),
1672 session_id: session.id,
1673 }))
1674 .await?;
1675 Ok(String::new())
1676 }
1677 Command::SelectPane {
1678 target,
1679 left,
1680 right,
1681 up,
1682 down,
1683 ..
1684 } => {
1685 let direction = [
1686 (left, NavigationDirection::Left),
1687 (right, NavigationDirection::Right),
1688 (up, NavigationDirection::Up),
1689 (down, NavigationDirection::Down),
1690 ]
1691 .into_iter()
1692 .find_map(|(set, direction)| set.then_some(direction));
1693 let pane = connection.resolve_pane(target.as_deref()).await?;
1694 let pane_id = match direction {
1695 None => pane.leaf_id,
1696 Some(direction) => {
1697 let (_, presentation) = formats::project(&pane.snapshot, pane.leaf_id)?;
1698 match presentation.focus_target_from(pane.leaf_id, direction) {
1699 Some(pane_id) => pane_id,
1700 None => return Ok(String::new()),
1703 }
1704 }
1705 };
1706 connection
1709 .request(ClientMessage::Node(NodeRequest::SelectPane {
1710 request_id: new_request_id(),
1711 session_id: pane.snapshot.session.id,
1712 node_id: pane_id,
1713 }))
1714 .await?;
1715 Ok(String::new())
1716 }
1717 Command::SetOption {
1718 pane,
1719 window,
1720 server,
1721 global,
1722 target,
1723 unset,
1724 only_if_unset,
1725 quiet,
1726 name,
1727 value,
1728 } => {
1729 connection
1730 .set_option(options::SetOption {
1731 scope: options::Scope {
1732 pane,
1733 window,
1734 server,
1735 global,
1736 },
1737 target,
1738 name,
1739 value,
1740 unset,
1741 only_if_unset,
1742 quiet,
1743 })
1744 .await
1745 }
1746 Command::ShowOptions {
1747 pane,
1748 window,
1749 server,
1750 global,
1751 target,
1752 value_only,
1753 quiet,
1754 name,
1755 } => {
1756 connection
1757 .show_options(options::ShowOptions {
1758 scope: options::Scope {
1759 pane,
1760 window,
1761 server,
1762 global,
1763 },
1764 target,
1765 name,
1766 value_only,
1767 quiet,
1768 })
1769 .await
1770 }
1771 Command::ResizePane {
1772 target,
1773 sizes,
1774 left,
1775 right,
1776 up,
1777 down,
1778 zoom,
1779 adjustment,
1780 } => {
1781 let direction = [
1782 (left, NavigationDirection::Left),
1783 (right, NavigationDirection::Right),
1784 (up, NavigationDirection::Up),
1785 (down, NavigationDirection::Down),
1786 ]
1787 .into_iter()
1788 .find_map(|(set, direction)| set.then_some(direction));
1789 if direction.is_none() && !zoom && sizes.is_empty() {
1790 return Err(MuxError::invalid_input(
1791 "resize-pane requires -L/-R/-U/-D, -Z, or --sizes",
1792 ));
1793 }
1794 let pane = connection.resolve_pane(target.as_deref()).await?;
1795 if zoom {
1796 let response = connection
1797 .request(ClientMessage::Node(NodeRequest::ToggleZoom {
1798 request_id: new_request_id(),
1799 node_id: pane.leaf_id,
1800 }))
1801 .await?;
1802 expect_ok(response, "resize-pane -Z")?;
1803 return Ok(String::new());
1804 }
1805 if let Some(direction) = direction {
1806 if let Some(resize) =
1807 plan_directional_resize(&pane, direction, adjustment.unwrap_or(1))?
1808 {
1809 connection
1810 .request(ClientMessage::Node(NodeRequest::Resize {
1811 request_id: new_request_id(),
1812 node_id: resize.split_node_id,
1813 sizes: resize.sizes,
1814 }))
1815 .await?;
1816 }
1817 return Ok(String::new());
1818 }
1819 let leaf = node_record(&pane.snapshot, pane.leaf_id)?;
1820 let parent_id = leaf
1821 .parent_id
1822 .ok_or_else(|| MuxError::invalid_input("pane is not inside a resizable split"))?;
1823 let parent = node_record(&pane.snapshot, parent_id)?;
1824 if parent.kind != embers_protocol::NodeRecordKind::Split {
1825 return Err(MuxError::invalid_input(
1826 "pane parent is not a split and cannot be resized",
1827 ));
1828 }
1829
1830 connection
1831 .request(ClientMessage::Node(NodeRequest::Resize {
1832 request_id: new_request_id(),
1833 node_id: parent_id,
1834 sizes,
1835 }))
1836 .await?;
1837 Ok(String::new())
1838 }
1839 Command::SendKeys {
1840 target,
1841 enter,
1842 keys,
1843 } => {
1844 let buffer_id = connection.resolve_buffer(target.as_deref()).await?;
1845 if keys.is_empty() && !enter {
1846 return Err(MuxError::invalid_input(
1847 "send-keys requires at least one key or --enter",
1848 ));
1849 }
1850 let mut bytes = keys.join(" ").into_bytes();
1851 if enter {
1852 bytes.push(b'\r');
1853 }
1854 connection
1855 .request(ClientMessage::Input(embers_protocol::InputRequest::Send {
1856 request_id: new_request_id(),
1857 buffer_id,
1858 bytes,
1859 }))
1860 .await?;
1861 Ok(String::new())
1862 }
1863 Command::CapturePane {
1864 target,
1865 print,
1866 start,
1867 end,
1868 join,
1869 escapes,
1870 } => {
1871 let buffer_id = connection.resolve_buffer(target.as_deref()).await?;
1872 if print || start.is_some() || end.is_some() || join || escapes {
1873 return connection
1874 .capture_lines(
1875 buffer_id,
1876 capture::CaptureOptions {
1877 start,
1878 end,
1879 join,
1880 escapes,
1881 },
1882 )
1883 .await;
1884 }
1885 let response = connection
1886 .request(ClientMessage::Buffer(BufferRequest::Capture {
1887 request_id: new_request_id(),
1888 buffer_id,
1889 }))
1890 .await?;
1891 let snapshot = expect_capture(response, "capture-pane")?;
1892 Ok(snapshot.lines.join("\n"))
1893 }
1894 Command::KillPane { target, detach } => {
1895 let pane = connection.resolve_pane(target.as_deref()).await?;
1896 connection
1897 .request(ClientMessage::Node(NodeRequest::Close {
1898 request_id: new_request_id(),
1899 node_id: pane.leaf_id,
1900 }))
1901 .await?;
1902 if !detach {
1903 connection
1904 .kill_detached_buffers(vec![pane.buffer_id])
1905 .await?;
1906 }
1907 Ok(String::new())
1908 }
1909 Command::DisplayPopup {
1910 target,
1911 title,
1912 x,
1913 y,
1914 width,
1915 height,
1916 close_on_exit,
1917 cwd,
1918 print,
1919 format,
1920 no_wait,
1921 no_border,
1922 help: _,
1923 command,
1924 } => {
1925 let (snapshot, pane_id) = match connection.resolve_pane(target.as_deref()).await {
1928 Ok(pane) => (pane.snapshot, Some(pane.leaf_id)),
1929 Err(error) => match target.as_deref() {
1930 Some(target) => (
1931 connection.resolve_session_snapshot(Some(target)).await?,
1932 None,
1933 ),
1934 None => return Err(error),
1935 },
1936 };
1937 let session_id = snapshot.session.id;
1938 let command = buffer_command(command);
1939 let popup_title = match title {
1940 Some(title) => {
1941 connection
1942 .expand_for_pane(&snapshot, pane_id, &title)
1943 .await?
1944 }
1945 None => default_title(&command, "popup"),
1946 };
1947 let cwd = match pane_id {
1948 Some(pane_id) => {
1949 connection
1950 .spawn_cwd_for_pane(cwd.as_deref(), &snapshot, pane_id)
1951 .await?
1952 }
1953 None => {
1954 connection
1955 .spawn_cwd(cwd.as_deref(), Some(&snapshot))
1956 .await?
1957 }
1958 };
1959 let geometry =
1960 popup::popup_geometry(formats::session_viewport(&snapshot), width, height, x, y);
1961 let wait = connection.wait_for_popups && !no_wait;
1962 let close = if close_on_exit >= 2 {
1963 popup::CloseOnExit::OnSuccess
1964 } else {
1965 popup::CloseOnExit::Always
1966 };
1967 let border = !no_border;
1968 let size = spawn_size(&snapshot, PanePlacement::Floating { geometry, border });
1969 let buffer = connection
1970 .create_buffer(Some(popup_title.clone()), command, cwd, size, session_id)
1971 .await?;
1972 let remain_on_exit = match (wait, close) {
1975 (true, _) => Some("on"),
1976 (false, popup::CloseOnExit::OnSuccess) => Some("failed"),
1977 (false, popup::CloseOnExit::Always) => None,
1978 };
1979 if let Some(value) = remain_on_exit {
1980 let response = connection
1981 .request(ClientMessage::Buffer(BufferRequest::SetUserOption {
1982 request_id: new_request_id(),
1983 buffer_id: buffer.buffer.id,
1984 key: popup::REMAIN_ON_EXIT.to_owned(),
1985 value: Some(value.to_owned()),
1986 }))
1987 .await;
1988 rollback_created_buffer_on_error(
1989 connection,
1990 buffer.buffer.id,
1991 "display-popup",
1992 response,
1993 )
1994 .await?;
1995 }
1996 let response = connection
1997 .request(ClientMessage::Floating(FloatingRequest::Create {
1998 request_id: new_request_id(),
1999 session_id,
2000 root_node_id: None,
2001 buffer_id: Some(buffer.buffer.id),
2002 geometry,
2003 title: Some(popup_title),
2004 focus: true,
2005 close_on_empty: true,
2006 border,
2007 }))
2008 .await;
2009 let response = rollback_created_buffer_on_error(
2010 connection,
2011 buffer.buffer.id,
2012 "display-popup",
2013 response,
2014 )
2015 .await?;
2016 let popup = rollback_created_buffer_on_error(
2017 connection,
2018 buffer.buffer.id,
2019 "display-popup",
2020 expect_floating(response, "display-popup"),
2021 )
2022 .await?;
2023 let output = if print {
2024 let snapshot = connection.session_snapshot(session_id).await?;
2025 let pane = pane_showing(&snapshot, buffer.buffer.id);
2026 let format = format
2027 .unwrap_or_else(|| "#{popup_id}".to_owned())
2028 .replace("#{popup_id}", &popup.id.to_string());
2029 connection.expand_for_pane(&snapshot, pane, &format).await?
2030 } else if wait {
2031 String::new()
2032 } else {
2033 popup.id.to_string()
2034 };
2035 if wait {
2036 if !output.is_empty() {
2037 println!("{output}");
2038 }
2039 let socket = connection.socket_path.clone();
2040 let status = connection
2041 .wait_for_popup(
2042 &socket,
2043 popup::WaitedPopup {
2044 session_id,
2045 floating_id: popup.id,
2046 buffer_id: buffer.buffer.id,
2047 close,
2048 },
2049 )
2050 .await?;
2051 connection.exit_status = Some(status);
2052 return Ok(String::new());
2053 }
2054 Ok(output)
2055 }
2056 Command::DisplayMessage {
2057 target,
2058 client,
2059 print,
2060 duration,
2061 message,
2062 } => {
2063 let client = client.or(connection.default_client);
2064 let template = if message.is_empty() {
2065 DEFAULT_DISPLAY_MESSAGE.to_owned()
2066 } else {
2067 message.join(" ")
2068 };
2069 let pane = match (&target, client) {
2070 (None, Some(client_id)) => {
2071 let session_id = connection
2072 .client_record(client_id)
2073 .await?
2074 .current_session_id
2075 .ok_or_else(|| {
2076 MuxError::not_found(format!("client {client_id} shows no session"))
2077 })?;
2078 let snapshot = connection.session_snapshot(session_id).await?;
2079 let pane_id = snapshot
2080 .session
2081 .focused_leaf_id
2082 .ok_or_else(|| MuxError::not_found("session has no focused pane"))?;
2083 resolved_pane(snapshot, pane_id)?
2084 }
2085 _ => connection.resolve_pane(target.as_deref()).await?,
2086 };
2087 let session_id = pane.snapshot.session.id;
2088 let text = connection
2089 .expand_for_pane(&pane.snapshot, Some(pane.leaf_id), &template)
2090 .await?;
2091 if print {
2092 return Ok(text);
2093 }
2094 let response = connection
2095 .request(ClientMessage::Client(ClientRequest::DisplayMessage {
2096 request_id: new_request_id(),
2097 client_id: client,
2098 session_id: client.is_none().then_some(session_id),
2099 message: text,
2100 duration_ms: duration.unwrap_or(0),
2101 }))
2102 .await?;
2103 expect_ok(response, "display-message")?;
2104 Ok(String::new())
2105 }
2106 Command::SetEnvironment {
2107 target,
2108 global,
2109 unset,
2110 name,
2111 value,
2112 } => {
2113 if !unset && value.is_none() {
2114 return Err(MuxError::invalid_input(format!(
2115 "set-environment {name} requires a value (or -u to unset it)"
2116 )));
2117 }
2118 let session_id = if global {
2119 None
2120 } else {
2121 Some(
2122 connection
2123 .resolve_session_record(target.as_deref())
2124 .await?
2125 .id,
2126 )
2127 };
2128 let response = connection
2129 .request(ClientMessage::Session(SessionRequest::SetEnvironment {
2130 request_id: new_request_id(),
2131 session_id,
2132 name,
2133 value,
2134 }))
2135 .await?;
2136 expect_ok(response, "set-environment")?;
2137 Ok(String::new())
2138 }
2139 Command::ShowEnvironment {
2140 target,
2141 global,
2142 name,
2143 } => {
2144 let session_id = if global {
2145 None
2146 } else {
2147 Some(
2148 connection
2149 .resolve_session_record(target.as_deref())
2150 .await?
2151 .id,
2152 )
2153 };
2154 let env = match connection
2155 .request(ClientMessage::Session(SessionRequest::ShowEnvironment {
2156 request_id: new_request_id(),
2157 session_id,
2158 }))
2159 .await?
2160 {
2161 ServerResponse::Environment(response) => response.env,
2162 other => {
2163 return Err(MuxError::protocol(format!(
2164 "unexpected response to show-environment: {other:?}"
2165 )));
2166 }
2167 };
2168 match name {
2169 Some(name) => env
2170 .get(&name)
2171 .map(|value| format!("{name}={value}"))
2172 .ok_or_else(|| MuxError::not_found(format!("unknown variable: {name}"))),
2173 None => Ok(env
2174 .iter()
2175 .map(|(name, value)| format!("{name}={value}"))
2176 .collect::<Vec<_>>()
2177 .join("\n")),
2178 }
2179 }
2180 Command::KillPopup { target, detach } => {
2181 let popup = connection.resolve_popup(target.as_deref()).await?;
2182 let snapshot = connection.session_snapshot(popup.session_id).await?;
2183 let shown = buffer_ids_in_subtree(&snapshot.nodes, popup.root_node_id);
2184 connection
2185 .request(ClientMessage::Floating(FloatingRequest::Close {
2186 request_id: new_request_id(),
2187 floating_id: popup.id,
2188 }))
2189 .await?;
2190 if !detach {
2191 connection.kill_detached_buffers(shown).await?;
2192 }
2193 Ok(String::new())
2194 }
2195 }
2196}
2197
2198pub async fn run(cli: Cli) -> Result<()> {
2199 let log_filter = cli.log_filter();
2200 let base_index = cli.base_index();
2201 let Cli {
2202 socket,
2203 config,
2204 command,
2205 ..
2206 } = cli;
2207
2208 match command {
2209 Some(Command::RuntimeKeeper {
2210 keeper_socket,
2211 cols,
2212 rows,
2213 pixel_width,
2214 pixel_height,
2215 cwd,
2216 env,
2217 command,
2218 }) => embers_server::run_runtime_keeper(embers_server::RuntimeKeeperCli {
2219 socket_path: keeper_socket,
2220 command,
2221 cwd,
2222 env: env.into_iter().collect(),
2223 size: embers_core::PtySize {
2224 cols,
2225 rows,
2226 pixel_width,
2227 pixel_height,
2228 },
2229 }),
2230 command => {
2231 let socket = resolve_socket_path(socket.as_deref());
2232 validate_runtime_socket_parent(&socket)?;
2233
2234 match command {
2235 None => {
2236 ensure_server_process(&socket, &log_filter).await?;
2237 interactive::run(socket, None, config).await
2238 }
2239 Some(Command::Attach { target }) => {
2240 if !server_is_available(&socket).await {
2241 return Err(MuxError::not_found(format!(
2242 "no embers server is listening on {}",
2243 socket.display()
2244 )));
2245 }
2246 let target = match target {
2247 Some(target) => Some(
2248 CliConnection::connect(&socket)
2249 .await?
2250 .resolve_session_record(Some(&target))
2251 .await?
2252 .name,
2253 ),
2254 None => None,
2255 };
2256 interactive::run(socket, target, config).await
2257 }
2258 Some(Command::Automation {
2259 target,
2260 all_sessions,
2261 }) => {
2262 ensure_server_process(&socket, &log_filter).await?;
2263 automation::run(socket, target, all_sessions).await
2264 }
2265 Some(Command::Serve) => run_server(socket, &log_filter).await,
2266 Some(Command::KillServer) => kill_server(&socket).await,
2267 Some(command) => {
2268 ensure_server_process(&socket, &log_filter).await?;
2269 let outcome = execute(&socket, command, base_index).await?;
2270 if !outcome.output.is_empty() {
2271 println!("{}", outcome.output);
2272 }
2273 if let Some(session) = outcome.attach_session {
2274 return interactive::run(socket, Some(session), config).await;
2275 }
2276 if let Some(status) = outcome.exit_status.filter(|status| *status != 0) {
2277 std::io::stdout().flush()?;
2278 std::process::exit(status);
2279 }
2280 Ok(())
2281 }
2282 }
2283 }
2284 }
2285}
2286
2287fn resolve_socket_path(explicit: Option<&Path>) -> PathBuf {
2288 explicit
2289 .map(Path::to_path_buf)
2290 .or_else(|| std::env::var_os(SOCKET_ENV_VAR).map(PathBuf::from))
2291 .unwrap_or_else(default_socket_path)
2292}
2293
2294fn default_socket_path() -> PathBuf {
2295 default_runtime_dir().join("embers.sock")
2296}
2297
2298fn default_runtime_dir() -> PathBuf {
2299 if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR").filter(|value| !value.is_empty())
2300 {
2301 return PathBuf::from(runtime_dir).join("embers");
2302 }
2303 #[cfg(unix)]
2304 {
2305 let run_user_dir = PathBuf::from(format!("/run/user/{}", effective_uid()));
2306 if run_user_dir.is_dir() {
2307 return run_user_dir.join("embers");
2308 }
2309 }
2310 PathBuf::from("/tmp").join(format!("embers-{}", effective_uid()))
2311}
2312
2313fn parse_env_arg(value: &str) -> std::result::Result<(String, OsString), String> {
2314 let Some((key, env_value)) = value.split_once('=') else {
2315 return Err("expected KEY=VALUE".to_owned());
2316 };
2317 if key.is_empty() {
2318 return Err("environment key must not be empty".to_owned());
2319 }
2320 Ok((key.to_owned(), decode_runtime_keeper_env_value(env_value)?))
2321}
2322
2323fn parse_string_env_arg(value: &str) -> std::result::Result<(String, String), String> {
2324 let Some((key, env_value)) = value.split_once('=') else {
2325 return Err("expected KEY=VALUE".to_owned());
2326 };
2327 if key.is_empty() {
2328 return Err("environment key must not be empty".to_owned());
2329 }
2330 Ok((key.to_owned(), env_value.to_owned()))
2331}
2332
2333fn decode_runtime_keeper_env_value(value: &str) -> std::result::Result<OsString, String> {
2334 let Some(encoded) = value.strip_prefix("base64:") else {
2335 return Ok(OsString::from(value));
2336 };
2337 let decoded = base64::engine::general_purpose::STANDARD
2338 .decode(encoded)
2339 .map_err(|error| format!("invalid base64 environment value: {error}"))?;
2340 #[cfg(unix)]
2341 {
2342 Ok(OsString::from_vec(decoded))
2343 }
2344 #[cfg(windows)]
2345 {
2346 if decoded.len() % 2 != 0 {
2347 return Err("invalid UTF-16LE environment value: odd-length byte sequence".to_owned());
2348 }
2349 let wide = decoded
2350 .chunks_exact(2)
2351 .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
2352 .collect::<Vec<_>>();
2353 Ok(OsString::from_wide(&wide))
2354 }
2355 #[cfg(all(not(unix), not(windows)))]
2356 {
2357 String::from_utf8(decoded)
2358 .map(OsString::from)
2359 .map_err(|error| format!("invalid UTF-8 environment value: {error}"))
2360 }
2361}
2362
2363#[cfg(unix)]
2364fn effective_uid() -> u32 {
2365 unsafe { libc::geteuid() }
2366}
2367
2368#[cfg(not(unix))]
2369fn effective_uid() -> u32 {
2370 0
2371}
2372
2373fn pid_path(socket_path: &Path) -> PathBuf {
2374 socket_path.with_extension("pid")
2375}
2376
2377async fn server_is_available(socket_path: &Path) -> bool {
2378 if validate_runtime_socket_parent(socket_path).is_err() {
2379 return false;
2380 }
2381 CliConnection::connect(socket_path).await.is_ok()
2382}
2383
2384async fn ensure_server_process(socket_path: &Path, log_filter: &str) -> Result<()> {
2385 if server_is_available(socket_path).await {
2386 return Ok(());
2387 }
2388
2389 ensure_socket_parent(socket_path)?;
2390
2391 let current_exe = std::env::current_exe()?;
2392 let mut child = ProcessCommand::new(current_exe)
2393 .arg("__serve")
2394 .arg("--socket")
2395 .arg(socket_path)
2396 .env(EMBERS_LOG_ENV_VAR, log_filter)
2400 .stdin(Stdio::null())
2401 .stdout(Stdio::null())
2402 .stderr(Stdio::null())
2403 .spawn()?;
2404
2405 let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
2406 let mut exited_status = None;
2407 loop {
2408 if server_is_available(socket_path).await {
2409 return Ok(());
2410 }
2411 if let Some(status) = child.try_wait()? {
2412 exited_status.get_or_insert(status);
2413 if server_is_available(socket_path).await {
2414 return Ok(());
2415 }
2416 }
2417 if tokio::time::Instant::now() >= deadline {
2418 if let Some(status) = exited_status {
2419 return Err(MuxError::transport(format!(
2420 "embers server exited before becoming ready with status {status}"
2421 )));
2422 }
2423 let _ = child.kill();
2424 let _ = child.wait();
2425 return Err(MuxError::timeout(format!(
2426 "timed out waiting for embers server at {}",
2427 socket_path.display()
2428 )));
2429 }
2430 sleep(Duration::from_millis(25)).await;
2431 }
2432}
2433
2434async fn run_server(socket_path: PathBuf, log_filter: &str) -> Result<()> {
2435 ensure_socket_parent(&socket_path)?;
2436 if let Some(dir) = socket_path.parent() {
2440 embers_core::init_server_tracing(log_filter, dir)?;
2441 install_server_panic_hook();
2442 }
2443 let secure_parent = socket_path
2444 .parent()
2445 .is_some_and(|parent| parent == default_runtime_dir().as_path());
2446 let _pid = ServerPidFile::create(&pid_path(&socket_path), secure_parent)?;
2447 let handle = Server::new(ServerConfig::new(socket_path)).start().await?;
2448 tokio::select! {
2450 result = wait_for_shutdown_signal() => result?,
2451 () = handle.stopped() => {}
2452 }
2453 handle.shutdown().await
2454}
2455
2456const KILL_SERVER_TIMEOUT: Duration = Duration::from_secs(10);
2458
2459async fn kill_server(socket_path: &Path) -> Result<()> {
2463 if !server_is_available(socket_path).await {
2464 return Err(MuxError::not_found(format!(
2465 "no embers server is listening on {}",
2466 socket_path.display()
2467 )));
2468 }
2469 execute(socket_path, Command::KillServer, None).await?;
2470 let deadline = tokio::time::Instant::now() + KILL_SERVER_TIMEOUT;
2471 while server_is_available(socket_path).await {
2472 if tokio::time::Instant::now() >= deadline {
2473 return Err(MuxError::timeout(format!(
2474 "embers server on {} did not stop",
2475 socket_path.display()
2476 )));
2477 }
2478 sleep(Duration::from_millis(25)).await;
2479 }
2480 Ok(())
2481}
2482
2483fn install_server_panic_hook() {
2487 let previous = std::panic::take_hook();
2488 std::panic::set_hook(Box::new(move |info| {
2489 tracing::error!(target: "panic", "server panicked: {info}");
2490 previous(info);
2491 }));
2492}
2493
2494fn ensure_socket_parent(socket_path: &Path) -> Result<()> {
2495 let Some(parent) = socket_path.parent() else {
2496 return Ok(());
2497 };
2498 if parent == default_runtime_dir().as_path() {
2499 ensure_private_dir(parent)
2500 } else {
2501 fs::create_dir_all(parent)?;
2502 Ok(())
2503 }
2504}
2505
2506fn ensure_private_dir(path: &Path) -> Result<()> {
2507 fs::create_dir_all(path)?;
2508 #[cfg(unix)]
2509 {
2510 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
2511 validate_private_dir(path)?;
2512 }
2513 Ok(())
2514}
2515
2516fn validate_runtime_socket_parent(socket_path: &Path) -> Result<()> {
2517 let Some(parent) = socket_path.parent() else {
2518 return Ok(());
2519 };
2520 if parent != default_runtime_dir().as_path() || !parent.exists() {
2521 return Ok(());
2522 }
2523 validate_private_dir(parent)
2524}
2525
2526#[cfg(unix)]
2527fn validate_private_dir(path: &Path) -> Result<()> {
2528 let metadata = fs::metadata(path)?;
2529 if !metadata.is_dir() {
2530 return Err(MuxError::invalid_input(format!(
2531 "runtime directory {} is not a directory",
2532 path.display()
2533 )));
2534 }
2535 if metadata.uid() != effective_uid() {
2536 return Err(MuxError::invalid_input(format!(
2537 "runtime directory {} is not owned by uid {}",
2538 path.display(),
2539 effective_uid()
2540 )));
2541 }
2542 if metadata.permissions().mode() & 0o777 != 0o700 {
2543 return Err(MuxError::invalid_input(format!(
2544 "runtime directory {} must have mode 0700",
2545 path.display()
2546 )));
2547 }
2548 Ok(())
2549}
2550
2551#[cfg(not(unix))]
2552fn validate_private_dir(_path: &Path) -> Result<()> {
2553 Ok(())
2554}
2555
2556#[cfg(unix)]
2557async fn wait_for_shutdown_signal() -> Result<()> {
2558 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2559 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2560 let mut hangup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
2561 tokio::select! {
2562 _ = interrupt.recv() => Ok(()),
2563 _ = terminate.recv() => Ok(()),
2564 _ = hangup.recv() => Ok(()),
2565 }
2566}
2567
2568#[cfg(not(unix))]
2569async fn wait_for_shutdown_signal() -> Result<()> {
2570 tokio::signal::ctrl_c().await?;
2571 Ok(())
2572}
2573
2574struct ServerPidFile {
2575 path: PathBuf,
2576}
2577
2578impl ServerPidFile {
2579 fn create(path: &Path, secure_parent: bool) -> Result<Self> {
2580 if let Some(parent) = path.parent() {
2581 if secure_parent {
2582 ensure_private_dir(parent)?;
2583 } else {
2584 fs::create_dir_all(parent)?;
2585 }
2586 }
2587 match fs::symlink_metadata(path) {
2588 Ok(metadata) => {
2589 if metadata.file_type().is_symlink() {
2590 return Err(MuxError::conflict(format!(
2591 "refusing to overwrite symlink pid file {}",
2592 path.display()
2593 )));
2594 }
2595 let pid_text = fs::read_to_string(path).map_err(|error| {
2596 MuxError::conflict(format!(
2597 "refusing to overwrite unreadable pid file {}: {error}",
2598 path.display()
2599 ))
2600 })?;
2601 let pid = pid_text.trim().parse::<u32>().map_err(|error| {
2602 MuxError::conflict(format!(
2603 "refusing to overwrite invalid pid file {}: {error}",
2604 path.display()
2605 ))
2606 })?;
2607 if process_is_alive(pid)? {
2608 return Err(MuxError::conflict(format!(
2609 "refusing to overwrite active pid file {} for running process {pid}",
2610 path.display()
2611 )));
2612 }
2613 fs::remove_file(path)?;
2614 }
2615 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2616 Err(error) => return Err(error.into()),
2617 }
2618
2619 #[cfg(unix)]
2620 let mut file = OpenOptions::new()
2621 .write(true)
2622 .create_new(true)
2623 .mode(0o600)
2624 .custom_flags(libc::O_NOFOLLOW)
2625 .open(path)?;
2626
2627 #[cfg(not(unix))]
2628 let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
2629
2630 file.write_all(std::process::id().to_string().as_bytes())?;
2631 Ok(Self {
2632 path: path.to_path_buf(),
2633 })
2634 }
2635}
2636
2637#[cfg(unix)]
2638fn process_is_alive(pid: u32) -> Result<bool> {
2639 let result = unsafe { libc::kill(pid as i32, 0) };
2640 if result == 0 {
2641 return Ok(true);
2642 }
2643 match std::io::Error::last_os_error().raw_os_error() {
2644 Some(libc::ESRCH) => Ok(false),
2645 Some(libc::EPERM) => Ok(true),
2646 Some(code) => Err(MuxError::transport(format!(
2647 "failed to validate process {pid}: os error {code}"
2648 ))),
2649 None => Err(MuxError::transport(format!(
2650 "failed to validate process {pid}: unknown os error"
2651 ))),
2652 }
2653}
2654
2655#[cfg(not(unix))]
2656fn process_is_alive(_pid: u32) -> Result<bool> {
2657 Err(MuxError::conflict(
2658 "pid file validation is unsupported on this platform".to_owned(),
2659 ))
2660}
2661
2662impl Drop for ServerPidFile {
2663 fn drop(&mut self) {
2664 let _ = std::fs::remove_file(&self.path);
2665 }
2666}
2667
2668pub struct PromptCommandRunner {
2673 socket_path: PathBuf,
2674}
2675
2676impl PromptCommandRunner {
2677 pub fn new(socket_path: PathBuf) -> Self {
2678 Self { socket_path }
2679 }
2680}
2681
2682#[async_trait::async_trait]
2683impl CommandRunner for PromptCommandRunner {
2684 async fn run(&self, argv: Vec<String>, context: CommandContext) -> Result<String> {
2685 let cli = Cli::try_parse_from(std::iter::once("embers".to_owned()).chain(argv))
2686 .map_err(|error| MuxError::invalid_input(clap_error_summary(&error)))?;
2687 if let Some(flag) = prompt_ignored_global_flag(&cli) {
2688 return Err(MuxError::invalid_input(format!(
2689 "{flag} cannot be used from the command prompt"
2690 )));
2691 }
2692 let base_index = cli.base_index.or(context.base_index);
2693 let command = cli
2694 .command
2695 .ok_or_else(|| MuxError::invalid_input("no command given"))?;
2696 if let Some(name) = interactive_command_name(&command) {
2697 return Err(MuxError::invalid_input(format!(
2698 "{name} cannot be run from the command prompt"
2699 )));
2700 }
2701 let mut connection = CliConnection::connect(&self.socket_path).await?;
2702 connection.base_index = base_index;
2703 connection.default_session = context.session_id;
2704 connection.default_client = context.client_id.and_then(NonZeroU64::new);
2705 execute_command(&mut connection, command).await
2706 }
2707}
2708
2709fn prompt_ignored_global_flag(cli: &Cli) -> Option<&'static str> {
2713 if cli.socket.is_some() {
2714 Some("--socket")
2715 } else if cli.config.is_some() {
2716 Some("--config")
2717 } else if cli.log.is_some() {
2718 Some("--log")
2719 } else if cli.verbose != 0 || cli.verbose_short != 0 {
2720 Some("--verbose")
2721 } else {
2722 None
2723 }
2724}
2725
2726fn clap_error_summary(error: &clap::Error) -> String {
2728 let rendered = error.to_string();
2729 let line = rendered.lines().next().unwrap_or_default();
2730 line.strip_prefix("error: ").unwrap_or(line).to_owned()
2731}
2732
2733fn interactive_command_name(command: &Command) -> Option<&'static str> {
2734 match command {
2735 Command::Attach { .. } => Some("attach"),
2736 Command::Serve => Some("__serve"),
2737 Command::RuntimeKeeper { .. } => Some("__runtime-keeper"),
2738 Command::Automation { .. } => Some("automation"),
2739 _ => None,
2740 }
2741}
2742
2743#[derive(Debug)]
2744struct CliConnection {
2745 client: ProtocolClient,
2746 base_index: Option<u32>,
2751 default_session: Option<SessionId>,
2754 default_client: Option<NonZeroU64>,
2757 pane_buffer: Option<BufferId>,
2761 caller_client: Option<NonZeroU64>,
2764 socket_path: PathBuf,
2765 working_dir: Option<String>,
2768 wait_for_popups: bool,
2771 can_attach: bool,
2773 attach_session: Option<String>,
2775 exit_status: Option<i32>,
2777}
2778
2779fn pane_buffer(
2784 buffer: Option<&str>,
2785 pane_socket: Option<&OsStr>,
2786 socket: &Path,
2787) -> Option<BufferId> {
2788 let buffer_id = buffer?.trim().parse::<u64>().ok()?;
2789 (Path::new(pane_socket?) == socket).then_some(BufferId(buffer_id))
2790}
2791
2792impl CliConnection {
2793 async fn connect(path: impl AsRef<Path>) -> Result<Self> {
2794 let client = ProtocolClient::connect(path.as_ref())
2795 .await
2796 .map_err(|error| MuxError::transport(error.to_string()))?;
2797 Ok(Self {
2798 client,
2799 base_index: None,
2800 default_session: None,
2801 default_client: None,
2802 pane_buffer: None,
2803 caller_client: None,
2804 socket_path: path.as_ref().to_path_buf(),
2805 working_dir: None,
2806 wait_for_popups: false,
2807 can_attach: false,
2808 attach_session: None,
2809 exit_status: None,
2810 })
2811 }
2812
2813 fn base_index_for(&self, snapshot: &SessionSnapshot) -> Option<u32> {
2817 self.base_index.or(snapshot.session.base_index)
2818 }
2819
2820 async fn pane_location(&mut self) -> Result<Option<BufferLocation>> {
2822 let Some(buffer_id) = self.pane_buffer else {
2823 return Ok(None);
2824 };
2825 let response = self
2826 .request(ClientMessage::Buffer(BufferRequest::GetLocation {
2827 request_id: new_request_id(),
2828 buffer_id,
2829 }))
2830 .await?;
2831 expect_buffer_location(response, "buffer location").map(Some)
2832 }
2833
2834 async fn resolve_buffer(&mut self, target: Option<&str>) -> Result<BufferId> {
2838 match (target, self.pane_buffer) {
2839 (None, Some(buffer_id)) => Ok(buffer_id),
2840 _ => Ok(self.resolve_pane(target).await?.buffer_id),
2841 }
2842 }
2843
2844 async fn request(&mut self, message: ClientMessage) -> Result<ServerResponse> {
2845 match self
2846 .client
2847 .request(&message)
2848 .await
2849 .map_err(|error| MuxError::transport(error.to_string()))?
2850 {
2851 ServerResponse::Error(response) => Err(response.error.into()),
2852 response => Ok(response),
2853 }
2854 }
2855
2856 async fn list_sessions(&mut self) -> Result<Vec<SessionRecord>> {
2857 match self
2858 .request(ClientMessage::Session(SessionRequest::List {
2859 request_id: new_request_id(),
2860 }))
2861 .await?
2862 {
2863 ServerResponse::Sessions(response) => Ok(response.sessions),
2864 other => Err(MuxError::protocol(format!(
2865 "unexpected response to list-sessions: {other:?}"
2866 ))),
2867 }
2868 }
2869
2870 async fn list_clients(&mut self) -> Result<Vec<ClientRecord>> {
2871 match self
2872 .request(ClientMessage::Client(ClientRequest::List {
2873 request_id: new_request_id(),
2874 }))
2875 .await?
2876 {
2877 ServerResponse::Clients(response) => Ok(response.clients),
2878 other => Err(MuxError::protocol(format!(
2879 "unexpected response to list-clients: {other:?}"
2880 ))),
2881 }
2882 }
2883
2884 async fn client_record(&mut self, client_id: NonZeroU64) -> Result<ClientRecord> {
2885 match self
2886 .request(ClientMessage::Client(ClientRequest::Get {
2887 request_id: new_request_id(),
2888 client_id: Some(client_id),
2889 }))
2890 .await?
2891 {
2892 ServerResponse::Client(response) => Ok(response.client),
2893 other => Err(MuxError::protocol(format!(
2894 "unexpected response to client get: {other:?}"
2895 ))),
2896 }
2897 }
2898
2899 async fn kill_detached_buffers(&mut self, buffer_ids: Vec<BufferId>) -> Result<()> {
2905 for buffer_id in buffer_ids {
2906 let buffer = match self
2907 .request(ClientMessage::Buffer(BufferRequest::Get {
2908 request_id: new_request_id(),
2909 buffer_id,
2910 }))
2911 .await
2912 {
2913 Ok(ServerResponse::Buffer(response)) => response.buffer,
2914 Ok(_) => continue,
2915 Err(error) if error.is_not_found() => continue,
2917 Err(error) => return Err(error),
2918 };
2919 if buffer.attachment_node_id.is_some()
2920 || !matches!(
2921 buffer.state,
2922 BufferRecordState::Running | BufferRecordState::Interrupted
2923 )
2924 {
2925 continue;
2926 }
2927 match self
2928 .request(ClientMessage::Buffer(BufferRequest::Kill {
2929 request_id: new_request_id(),
2930 buffer_id,
2931 force: false,
2932 }))
2933 .await
2934 {
2935 Ok(_) => {}
2936 Err(error) if error.is_not_found() || error.is_conflict() => {}
2937 Err(error) => return Err(error),
2938 }
2939 }
2940 Ok(())
2941 }
2942
2943 async fn session_snapshot(&mut self, session_id: SessionId) -> Result<SessionSnapshot> {
2944 match self
2945 .request(ClientMessage::Session(SessionRequest::Get {
2946 request_id: new_request_id(),
2947 session_id,
2948 }))
2949 .await?
2950 {
2951 ServerResponse::SessionSnapshot(response) => Ok(response.snapshot),
2952 other => Err(MuxError::protocol(format!(
2953 "unexpected response to session get: {other:?}"
2954 ))),
2955 }
2956 }
2957
2958 async fn create_buffer(
2959 &mut self,
2960 title: Option<String>,
2961 command: Vec<String>,
2962 cwd: Option<String>,
2963 size: Option<PtySize>,
2964 session_id: SessionId,
2965 ) -> Result<BufferResponse> {
2966 match self
2967 .request(ClientMessage::Buffer(BufferRequest::Create {
2968 request_id: new_request_id(),
2969 title,
2970 command,
2971 cwd,
2972 env: Default::default(),
2973 size,
2974 session_id: Some(session_id),
2975 }))
2976 .await?
2977 {
2978 ServerResponse::Buffer(response) => Ok(response),
2979 other => Err(MuxError::protocol(format!(
2980 "unexpected response to buffer create: {other:?}"
2981 ))),
2982 }
2983 }
2984
2985 async fn rollback_created_buffer(&mut self, buffer_id: BufferId, operation: &str) {
2986 if let Err(error) = self
2987 .request(ClientMessage::Buffer(BufferRequest::Detach {
2988 request_id: new_request_id(),
2989 buffer_id,
2990 }))
2991 .await
2992 {
2993 warn!(
2994 %buffer_id,
2995 %error,
2996 operation,
2997 "failed to detach created buffer during rollback"
2998 );
2999 }
3000 if let Err(error) = self
3001 .request(ClientMessage::Buffer(BufferRequest::Kill {
3002 request_id: new_request_id(),
3003 buffer_id,
3004 force: true,
3005 }))
3006 .await
3007 {
3008 warn!(
3009 %buffer_id,
3010 %error,
3011 operation,
3012 "failed to kill created buffer during rollback"
3013 );
3014 }
3015 }
3016
3017 async fn resolve_session_record(&mut self, target: Option<&str>) -> Result<SessionRecord> {
3019 self.target_session(target).await
3020 }
3021
3022 async fn resolve_session_snapshot(&mut self, target: Option<&str>) -> Result<SessionSnapshot> {
3023 let session = self.resolve_session_record(target).await?;
3024 self.session_snapshot(session.id).await
3025 }
3026
3027 async fn resolve_window(&mut self, target: Option<&str>) -> Result<ResolvedWindow> {
3029 self.target_window(target).await
3030 }
3031
3032 async fn resolve_pane(&mut self, target: Option<&str>) -> Result<ResolvedPane> {
3034 self.target_pane(target).await
3035 }
3036
3037 async fn resolve_popup(&mut self, target: Option<&str>) -> Result<FloatingRecord> {
3038 match target {
3039 Some(target) => {
3040 let (session_target, selector) = split_scoped_required(target, "popup target")?;
3041 let popup_id = parse_floating_id(&selector)?;
3042 if let Some(session_target) = session_target {
3043 let snapshot = self.resolve_session_snapshot(Some(&session_target)).await?;
3044 floating_record(&snapshot, popup_id).cloned()
3045 } else {
3046 let sessions = self.list_sessions().await?;
3047 for session in sessions {
3048 let snapshot = self.session_snapshot(session.id).await?;
3049 if let Ok(popup) = floating_record(&snapshot, popup_id) {
3050 return Ok(popup.clone());
3051 }
3052 }
3053 Err(MuxError::not_found(format!(
3054 "popup {popup_id} was not found"
3055 )))
3056 }
3057 }
3058 None => {
3059 if let Some(BufferLocationAttachment::Floating {
3060 session_id,
3061 floating_id,
3062 ..
3063 }) = self
3064 .pane_location()
3065 .await?
3066 .map(|location| location.attachment)
3067 {
3068 let snapshot = self.session_snapshot(session_id).await?;
3069 return floating_record(&snapshot, floating_id).cloned();
3070 }
3071 let snapshot = self.resolve_session_snapshot(None).await?;
3072 let popup_id = snapshot
3073 .session
3074 .focused_floating_id
3075 .ok_or_else(|| MuxError::not_found("session has no focused popup"))?;
3076 floating_record(&snapshot, popup_id).cloned()
3077 }
3078 }
3079 }
3080
3081 async fn find_session_containing_pane(&mut self, pane_id: NodeId) -> Result<SessionSnapshot> {
3082 let sessions = self.list_sessions().await?;
3083 for session in sessions {
3084 let snapshot = self.session_snapshot(session.id).await?;
3085 if node_record(&snapshot, pane_id).is_ok() {
3086 return Ok(snapshot);
3087 }
3088 }
3089
3090 Err(MuxError::not_found(format!("pane {pane_id} was not found")))
3091 }
3092}
3093
3094async fn rollback_created_buffer_on_error<T>(
3095 connection: &mut CliConnection,
3096 buffer_id: BufferId,
3097 operation: &str,
3098 result: Result<T>,
3099) -> Result<T> {
3100 match result {
3101 Ok(value) => Ok(value),
3102 Err(error) => {
3103 connection
3104 .rollback_created_buffer(buffer_id, operation)
3105 .await;
3106 Err(error)
3107 }
3108 }
3109}
3110
3111#[derive(Debug)]
3112struct ResolvedWindow {
3113 snapshot: SessionSnapshot,
3114 index: u32,
3115 child_id: NodeId,
3116}
3117
3118#[derive(Debug)]
3119struct ResolvedPane {
3120 snapshot: SessionSnapshot,
3121 leaf_id: NodeId,
3122 buffer_id: BufferId,
3123}
3124
3125fn window_containing(snapshot: &SessionSnapshot, node_id: NodeId) -> Result<(u32, NodeId)> {
3127 let (tabs_node, tabs) = root_tabs(snapshot)?;
3128 let mut child_id = node_id;
3129 loop {
3130 let parent_id = node_record(snapshot, child_id)?.parent_id.ok_or_else(|| {
3131 MuxError::not_found(format!("node {node_id} is not in a window of its session"))
3132 })?;
3133 if parent_id == tabs_node.id {
3134 break;
3135 }
3136 child_id = parent_id;
3137 }
3138 let index = tabs
3139 .tabs
3140 .iter()
3141 .position(|tab| tab.child_id == child_id)
3142 .ok_or_else(|| MuxError::protocol(format!("window of node {node_id} is not a root tab")))?;
3143 Ok((
3144 u32::try_from(index).expect("tab index fits into protocol width"),
3145 child_id,
3146 ))
3147}
3148
3149fn resolved_pane(snapshot: SessionSnapshot, pane_id: NodeId) -> Result<ResolvedPane> {
3150 let leaf = node_record(&snapshot, pane_id)?;
3151 let buffer_id = leaf
3152 .buffer_view
3153 .as_ref()
3154 .map(|view| view.buffer_id)
3155 .ok_or_else(|| MuxError::invalid_input(format!("node {pane_id} is not a pane leaf")))?;
3156 Ok(ResolvedPane {
3157 snapshot,
3158 leaf_id: pane_id,
3159 buffer_id,
3160 })
3161}
3162
3163fn expect_session_snapshot(response: ServerResponse, operation: &str) -> Result<SessionSnapshot> {
3164 match response {
3165 ServerResponse::SessionSnapshot(response) => Ok(response.snapshot),
3166 other => Err(MuxError::protocol(format!(
3167 "unexpected response to {operation}: {other:?}"
3168 ))),
3169 }
3170}
3171
3172fn expect_floating(response: ServerResponse, operation: &str) -> Result<FloatingRecord> {
3173 match response {
3174 ServerResponse::Floating(FloatingResponse { floating, .. }) => Ok(floating),
3175 other => Err(MuxError::protocol(format!(
3176 "unexpected response to {operation}: {other:?}"
3177 ))),
3178 }
3179}
3180
3181fn expect_capture(response: ServerResponse, operation: &str) -> Result<SnapshotResponse> {
3182 match response {
3183 ServerResponse::Snapshot(snapshot) => Ok(snapshot),
3184 other => Err(MuxError::protocol(format!(
3185 "unexpected response to {operation}: {other:?}"
3186 ))),
3187 }
3188}
3189
3190fn expect_buffer_location(response: ServerResponse, operation: &str) -> Result<BufferLocation> {
3191 match response {
3192 ServerResponse::BufferLocation(BufferLocationResponse { location, .. }) => Ok(location),
3193 other => Err(MuxError::protocol(format!(
3194 "unexpected response to {operation}: {other:?}"
3195 ))),
3196 }
3197}
3198
3199fn expect_ok(response: ServerResponse, operation: &str) -> Result<()> {
3200 match response {
3201 ServerResponse::Ok(_) => Ok(()),
3202 other => Err(MuxError::protocol(format!(
3203 "unexpected response to {operation}: {other:?}"
3204 ))),
3205 }
3206}
3207
3208fn expect_buffer_with_location(
3209 response: ServerResponse,
3210 operation: &str,
3211) -> Result<(embers_protocol::BufferRecord, BufferLocation, bool)> {
3212 match response {
3213 ServerResponse::BufferWithLocation(response) => {
3214 let (_, buffer, location, at_root_tab) = response.into_parts();
3215 if buffer.id != location.buffer_id {
3216 return Err(MuxError::protocol(format!(
3217 "{operation} returned buffer {} but location was for buffer {}",
3218 buffer.id, location.buffer_id
3219 )));
3220 }
3221 Ok((buffer, location, at_root_tab))
3222 }
3223 other => Err(MuxError::protocol(format!(
3224 "unexpected response to {operation}: {other:?}"
3225 ))),
3226 }
3227}
3228
3229fn ensure_matching_buffer_id(
3230 operation: &str,
3231 requested_buffer_id: BufferId,
3232 actual_buffer_id: BufferId,
3233) -> Result<()> {
3234 if actual_buffer_id == requested_buffer_id {
3235 return Ok(());
3236 }
3237
3238 Err(MuxError::protocol(format!(
3239 "{operation} returned buffer {actual_buffer_id} for requested buffer {requested_buffer_id}"
3240 )))
3241}
3242
3243fn format_sessions(sessions: &[SessionRecord]) -> String {
3244 sessions
3245 .iter()
3246 .map(|session| format!("{}\t{}", session.id, session.name))
3247 .collect::<Vec<_>>()
3248 .join("\n")
3249}
3250
3251fn format_buffers(buffers: &[embers_protocol::BufferRecord]) -> String {
3252 buffers
3253 .iter()
3254 .map(|buffer| {
3255 let attachment = buffer
3256 .attachment_node_id
3257 .map(|node_id| format!("attached:{node_id}"))
3258 .unwrap_or_else(|| "detached".to_owned());
3259 format!(
3260 "{}\t{}\t{}\t{}",
3261 buffer.id,
3262 buffer_state_label(buffer.state),
3263 attachment,
3264 buffer.title
3265 )
3266 })
3267 .collect::<Vec<_>>()
3268 .join("\n")
3269}
3270
3271fn format_clients(clients: &[ClientRecord], sessions: &[SessionRecord]) -> String {
3272 clients
3273 .iter()
3274 .map(|client| {
3275 let current = client
3276 .current_session_id
3277 .map(|session_id| session_label(sessions, session_id))
3278 .unwrap_or_else(|| "-".to_owned());
3279 let scope = if client.subscribed_all_sessions {
3280 "all".to_owned()
3281 } else if client.subscribed_session_ids.is_empty() {
3282 "-".to_owned()
3283 } else {
3284 client
3285 .subscribed_session_ids
3286 .iter()
3287 .map(|session_id| session_label(sessions, *session_id))
3288 .collect::<Vec<_>>()
3289 .join(",")
3290 };
3291 format!("{}\t{}\t{}", client.id, current, scope)
3292 })
3293 .collect::<Vec<_>>()
3294 .join("\n")
3295}
3296
3297fn format_buffer_details(
3298 buffer: &embers_protocol::BufferRecord,
3299 location: &BufferLocation,
3300) -> String {
3301 let mut lines = vec![
3302 format!("id\t{}", buffer.id),
3303 format!(
3304 "title\t{}",
3305 serde_json::to_string(&buffer.title).expect("buffer titles serialize to JSON")
3306 ),
3307 format!("state\t{}", buffer_state_label(buffer.state)),
3308 format!("kind\t{}", buffer_kind_label(buffer.kind)),
3309 format!("read_only\t{}", usize::from(buffer.read_only)),
3310 format!("location\t{}", format_buffer_location_inline(location)),
3311 ];
3312 if let Some(source_buffer_id) = buffer.helper_source_buffer_id {
3313 lines.push(format!("source_buffer\t{}", source_buffer_id));
3314 }
3315 if let Some(scope) = buffer.helper_scope {
3316 lines.push(format!("history_scope\t{}", history_scope_label(scope)));
3317 }
3318 if !buffer.command.is_empty() {
3319 let serialized_args =
3320 serde_json::to_string(&buffer.command).expect("buffer commands serialize to JSON");
3321 lines.push(format!("command\t{serialized_args}"));
3322 }
3323 if let Some(name) = buffer.process_name() {
3326 let serialized_name =
3327 serde_json::to_string(&name).expect("program names serialize to JSON");
3328 lines.push(format!("current_command\t{serialized_name}"));
3329 }
3330 if let Some(foreground) = &buffer.foreground_command {
3331 let serialized_foreground =
3332 serde_json::to_string(foreground).expect("foreground commands serialize to JSON");
3333 lines.push(format!("foreground_command\t{serialized_foreground}"));
3334 }
3335 if let Some(cwd) = &buffer.cwd {
3336 let serialized_cwd =
3337 serde_json::to_string(cwd).expect("buffer working directories serialize to JSON");
3338 lines.push(format!("cwd\t{serialized_cwd}"));
3339 }
3340 if let Some(pipe) = &buffer.pipe {
3341 lines.push(format!(
3342 "pipe_state\t{}",
3343 buffer_pipe_state_label(pipe.state)
3344 ));
3345 let serialized_command =
3346 serde_json::to_string(&pipe.command).expect("buffer pipe commands serialize to JSON");
3347 lines.push(format!("pipe_command\t{serialized_command}"));
3348 if let Some(pid) = pipe.pid {
3349 lines.push(format!("pipe_pid\t{pid}"));
3350 }
3351 if let Some(exit_code) = pipe.exit_code {
3352 lines.push(format!("pipe_exit_code\t{exit_code}"));
3353 }
3354 if let Some(reason) = pipe.stop_reason {
3355 lines.push(format!(
3356 "pipe_stop_reason\t{}",
3357 buffer_pipe_stop_reason_label(reason)
3358 ));
3359 }
3360 }
3361 lines.join("\n")
3362}
3363
3364fn format_buffer_pipe_details(
3365 buffer_id: BufferId,
3366 pipe: Option<&BufferPipeRecord>,
3367) -> Result<String> {
3368 let Some(pipe) = pipe else {
3369 return Err(MuxError::not_found(format!(
3370 "buffer {buffer_id} has no pipe state"
3371 )));
3372 };
3373 let mut lines = vec![
3374 format!("buffer_id\t{buffer_id}"),
3375 format!("state\t{}", buffer_pipe_state_label(pipe.state)),
3376 format!(
3377 "command\t{}",
3378 serde_json::to_string(&pipe.command).expect("buffer pipe commands serialize to JSON")
3379 ),
3380 ];
3381 if let Some(pid) = pipe.pid {
3382 lines.push(format!("pid\t{pid}"));
3383 }
3384 if let Some(exit_code) = pipe.exit_code {
3385 lines.push(format!("exit_code\t{exit_code}"));
3386 }
3387 if let Some(reason) = pipe.stop_reason {
3388 lines.push(format!(
3389 "stop_reason\t{}",
3390 buffer_pipe_stop_reason_label(reason)
3391 ));
3392 }
3393 Ok(lines.join("\n"))
3394}
3395
3396fn format_buffer_location_line(location: &BufferLocation) -> String {
3397 format!(
3398 "{}\t{}",
3399 location.buffer_id,
3400 format_buffer_location_value(location)
3401 )
3402}
3403
3404fn format_buffer_location_inline(location: &BufferLocation) -> String {
3405 match location.attachment {
3406 BufferLocationAttachment::Floating {
3407 session_id,
3408 node_id,
3409 floating_id,
3410 } => {
3411 format!("session:{session_id} node:{node_id} floating:{floating_id}")
3412 }
3413 BufferLocationAttachment::Session {
3414 session_id,
3415 node_id,
3416 } => format!("session:{session_id} node:{node_id}"),
3417 BufferLocationAttachment::Detached => "detached".to_owned(),
3418 }
3419}
3420
3421fn format_buffer_location_value(location: &BufferLocation) -> String {
3422 match location.attachment {
3423 BufferLocationAttachment::Floating {
3424 session_id,
3425 node_id,
3426 floating_id,
3427 } => {
3428 format!("session:{session_id}\tnode:{node_id}\tfloating:{floating_id}")
3429 }
3430 BufferLocationAttachment::Session {
3431 session_id,
3432 node_id,
3433 } => format!("session:{session_id}\tnode:{node_id}"),
3434 BufferLocationAttachment::Detached => "detached".to_owned(),
3435 }
3436}
3437
3438fn session_label(sessions: &[SessionRecord], session_id: SessionId) -> String {
3439 sessions
3440 .iter()
3441 .find(|session| session.id == session_id)
3442 .map(|session| format!("{}:{}", session.id, session.name))
3443 .unwrap_or_else(|| session_id.to_string())
3444}
3445
3446fn format_windows(snapshot: &SessionSnapshot, base_index: Option<u32>) -> Result<String> {
3447 let (_, tabs) = root_tabs(snapshot)?;
3448 Ok(tabs
3449 .tabs
3450 .iter()
3451 .enumerate()
3452 .map(|(index, tab)| {
3453 format!(
3454 "{}\t{}\t{}",
3455 window_number(index, base_index),
3456 usize::from(u32::try_from(index).ok() == Some(tabs.active)),
3457 tab.title
3458 )
3459 })
3460 .collect::<Vec<_>>()
3461 .join("\n"))
3462}
3463
3464fn format_panes(snapshot: &SessionSnapshot, pane_ids: &[NodeId]) -> Result<String> {
3465 pane_ids
3466 .iter()
3467 .map(|pane_id| {
3468 let leaf = node_record(snapshot, *pane_id)?;
3469 let buffer_id = leaf
3470 .buffer_view
3471 .as_ref()
3472 .map(|view| view.buffer_id)
3473 .ok_or_else(|| MuxError::invalid_input(format!("node {pane_id} is not a pane")))?;
3474 let buffer = buffer_record(snapshot, buffer_id)?;
3475 Ok(format!(
3476 "{}\t{}\t{}\t{}",
3477 pane_id,
3478 buffer.id,
3479 usize::from(snapshot.session.focused_leaf_id == Some(*pane_id)),
3480 buffer.title
3481 ))
3482 })
3483 .collect::<Result<Vec<_>>>()
3484 .map(|lines| lines.join("\n"))
3485}
3486
3487fn pane_showing(snapshot: &SessionSnapshot, buffer_id: BufferId) -> Option<NodeId> {
3489 snapshot
3490 .nodes
3491 .iter()
3492 .find(|node| {
3493 node.buffer_view
3494 .as_ref()
3495 .is_some_and(|view| view.buffer_id == buffer_id)
3496 })
3497 .map(|node| node.id)
3498}
3499
3500fn window_children(snapshot: &SessionSnapshot) -> Result<Vec<NodeId>> {
3502 let (_, tabs) = root_tabs(snapshot)?;
3503 Ok(tabs.tabs.iter().map(|tab| tab.child_id).collect())
3504}
3505
3506fn window_panes(snapshot: &SessionSnapshot) -> Result<Vec<Option<NodeId>>> {
3508 window_children(snapshot)?
3509 .into_iter()
3510 .map(|child_id| {
3511 let leaves = visible_leaf_ids(snapshot, child_id)?;
3512 Ok(window_active_pane(snapshot, child_id, &leaves))
3513 })
3514 .collect()
3515}
3516
3517fn window_active_pane(
3521 snapshot: &SessionSnapshot,
3522 child_id: NodeId,
3523 leaves: &[NodeId],
3524) -> Option<NodeId> {
3525 snapshot
3526 .session
3527 .focused_leaf_id
3528 .filter(|leaf_id| leaves.contains(leaf_id))
3529 .or_else(|| {
3530 node_record(snapshot, child_id)
3531 .ok()
3532 .and_then(|node| node.focus_leaf_id)
3533 .filter(|leaf_id| leaves.contains(leaf_id))
3534 })
3535 .or_else(|| leaves.first().copied())
3536}
3537
3538fn current_root_window(snapshot: &SessionSnapshot) -> Result<Option<u32>> {
3540 let (_, tabs) = root_tabs(snapshot)?;
3541 Ok((!tabs.tabs.is_empty()).then_some(tabs.active))
3542}
3543
3544async fn restore_current(
3548 connection: &mut CliConnection,
3549 mut snapshot: SessionSnapshot,
3550 window: Option<u32>,
3551 leaf: Option<NodeId>,
3552 context: &str,
3553) -> Result<SessionSnapshot> {
3554 let session_id = snapshot.session.id;
3555 if let Some(index) = window {
3556 let response = connection
3557 .request(ClientMessage::Session(SessionRequest::SelectRootTab {
3558 request_id: new_request_id(),
3559 session_id,
3560 index,
3561 }))
3562 .await?;
3563 snapshot = expect_session_snapshot(response, context)?;
3564 }
3565 if let Some(leaf) = leaf.filter(|leaf| snapshot.session.focused_leaf_id != Some(*leaf)) {
3566 let response = connection
3567 .request(ClientMessage::Node(NodeRequest::Focus {
3568 request_id: new_request_id(),
3569 session_id,
3570 node_id: leaf,
3571 }))
3572 .await?;
3573 snapshot = expect_session_snapshot(response, context)?;
3574 }
3575 Ok(snapshot)
3576}
3577
3578fn active_root_window(snapshot: &SessionSnapshot) -> Result<(u32, String)> {
3579 let (_, tabs) = root_tabs(snapshot)?;
3580 let tab = protocol_tab(tabs, tabs.active)
3581 .ok_or_else(|| MuxError::protocol("session root tabs has invalid active index"))?;
3582 Ok((tabs.active, tab.title.clone()))
3583}
3584
3585fn plan_directional_resize(
3590 pane: &ResolvedPane,
3591 direction: NavigationDirection,
3592 cells: u16,
3593) -> Result<Option<SplitResize>> {
3594 let session_id = pane.snapshot.session.id;
3595 let mut state = ClientState::default();
3596 state.apply_session_snapshot(pane.snapshot.clone());
3597 let viewport = drawn_viewport(&state, session_id).ok_or_else(|| {
3598 MuxError::invalid_input("cannot determine the size the session is drawn at")
3599 })?;
3600 let presentation = PresentationModel::project_with(
3601 &state,
3602 session_id,
3603 viewport,
3604 PaneTitles::reported(&pane.snapshot.session),
3605 )?;
3606 Ok(presentation.resize_leaf(&state, pane.leaf_id, direction, cells))
3607}
3608
3609fn root_tabs(
3611 snapshot: &SessionSnapshot,
3612) -> Result<(&embers_protocol::NodeRecord, &embers_protocol::TabsRecord)> {
3613 let node = node_record(snapshot, snapshot.session.root_node_id)?;
3614 let tabs = node.tabs.as_ref().ok_or_else(|| {
3615 MuxError::protocol(format!(
3616 "session {} root node {} is not tabs",
3617 snapshot.session.id, node.id
3618 ))
3619 })?;
3620 Ok((node, tabs))
3621}
3622
3623fn node_record(
3624 snapshot: &SessionSnapshot,
3625 node_id: NodeId,
3626) -> Result<&embers_protocol::NodeRecord> {
3627 snapshot
3628 .nodes
3629 .iter()
3630 .find(|node| node.id == node_id)
3631 .ok_or_else(|| MuxError::not_found(format!("node {node_id} is not present in snapshot")))
3632}
3633
3634fn buffer_record(
3635 snapshot: &SessionSnapshot,
3636 buffer_id: BufferId,
3637) -> Result<&embers_protocol::BufferRecord> {
3638 snapshot
3639 .buffers
3640 .iter()
3641 .find(|buffer| buffer.id == buffer_id)
3642 .ok_or_else(|| {
3643 MuxError::not_found(format!("buffer {buffer_id} is not present in snapshot"))
3644 })
3645}
3646
3647fn floating_record(snapshot: &SessionSnapshot, floating_id: FloatingId) -> Result<&FloatingRecord> {
3648 snapshot
3649 .floating
3650 .iter()
3651 .find(|floating| floating.id == floating_id)
3652 .ok_or_else(|| {
3653 MuxError::not_found(format!("popup {floating_id} is not present in snapshot"))
3654 })
3655}
3656
3657fn visible_leaf_ids(snapshot: &SessionSnapshot, node_id: NodeId) -> Result<Vec<NodeId>> {
3658 let node = node_record(snapshot, node_id)?;
3659 match node.kind {
3660 embers_protocol::NodeRecordKind::BufferView => Ok(vec![node.id]),
3661 embers_protocol::NodeRecordKind::Split => {
3662 let split = node
3663 .split
3664 .as_ref()
3665 .ok_or_else(|| MuxError::protocol(format!("split node {node_id} is malformed")))?;
3666 let mut leaves = Vec::new();
3667 for child_id in &split.child_ids {
3668 leaves.extend(visible_leaf_ids(snapshot, *child_id)?);
3669 }
3670 Ok(leaves)
3671 }
3672 embers_protocol::NodeRecordKind::Tabs => {
3673 let tabs = node
3674 .tabs
3675 .as_ref()
3676 .ok_or_else(|| MuxError::protocol(format!("tabs node {node_id} is malformed")))?;
3677 let active_child = protocol_tab(tabs, tabs.active).ok_or_else(|| {
3678 MuxError::protocol(format!("tabs node {node_id} has invalid active index"))
3679 })?;
3680 visible_leaf_ids(snapshot, active_child.child_id)
3681 }
3682 }
3683}
3684
3685fn resolve_window_index(
3686 tabs: &embers_protocol::TabsRecord,
3687 selector: Option<&str>,
3688 base_index: Option<u32>,
3689) -> Result<u32> {
3690 let Some(selector) = selector else {
3691 return Ok(tabs.active);
3692 };
3693
3694 if let Some(base_index) = base_index
3695 && let Ok(number) = selector.parse::<u64>()
3696 {
3697 return window_position(number, tabs.tabs.len(), base_index)
3698 .map(|position| u32::try_from(position).expect("tab index fits into protocol width"))
3699 .ok_or_else(|| {
3700 MuxError::not_found(format!("window index '{selector}' is out of range"))
3701 });
3702 }
3703
3704 if let Ok(index) = selector.parse::<u32>() {
3705 let mut candidates = Vec::new();
3706 if protocol_tab(tabs, index).is_some() {
3707 candidates.push(index);
3708 }
3709 if let Some(one_based) = index.checked_sub(1)
3710 && protocol_tab(tabs, one_based).is_some()
3711 {
3712 candidates.push(one_based);
3713 }
3714 candidates.sort_unstable();
3715 candidates.dedup();
3716 return match candidates.as_slice() {
3717 [only] => Ok(*only),
3718 [] => Err(MuxError::not_found(format!(
3719 "window index '{selector}' is out of range"
3720 ))),
3721 _ => Err(MuxError::invalid_input(format!(
3722 "window index '{selector}' is ambiguous between 0-based and 1-based addressing"
3723 ))),
3724 };
3725 }
3726
3727 let matches = tabs
3728 .tabs
3729 .iter()
3730 .enumerate()
3731 .filter(|(_, tab)| tab.title == selector)
3732 .map(|(index, _)| u32::try_from(index).expect("tab index fits into protocol width"))
3733 .collect::<Vec<_>>();
3734
3735 match matches.as_slice() {
3736 [index] => Ok(*index),
3737 [] => Err(MuxError::not_found(format!(
3738 "window '{selector}' was not found"
3739 ))),
3740 _ => Err(MuxError::conflict(format!(
3741 "window title '{selector}' matched multiple root tabs"
3742 ))),
3743 }
3744}
3745
3746fn protocol_tab(
3747 tabs: &embers_protocol::TabsRecord,
3748 index: u32,
3749) -> Option<&embers_protocol::TabRecord> {
3750 usize::try_from(index)
3751 .ok()
3752 .and_then(|index| tabs.tabs.get(index))
3753}
3754
3755fn split_scoped_target(target: Option<&str>) -> (Option<String>, Option<String>) {
3756 match target {
3757 Some(target) => {
3758 if let Some((session, selector)) = target.split_once(':') {
3759 (Some(session.to_owned()), Some(selector.to_owned()))
3760 } else {
3761 (None, Some(target.to_owned()))
3762 }
3763 }
3764 None => (None, None),
3765 }
3766}
3767
3768fn buffer_state_label(state: embers_protocol::BufferRecordState) -> &'static str {
3769 match state {
3770 embers_protocol::BufferRecordState::Created => "created",
3771 embers_protocol::BufferRecordState::Running => "running",
3772 embers_protocol::BufferRecordState::Interrupted => "interrupted",
3773 embers_protocol::BufferRecordState::Exited => "exited",
3774 }
3775}
3776
3777fn buffer_kind_label(kind: embers_protocol::BufferRecordKind) -> &'static str {
3778 match kind {
3779 embers_protocol::BufferRecordKind::Pty => "pty",
3780 embers_protocol::BufferRecordKind::Helper => "helper",
3781 }
3782}
3783
3784fn buffer_pipe_state_label(state: BufferPipeState) -> &'static str {
3785 match state {
3786 BufferPipeState::Running => "running",
3787 BufferPipeState::Stopped => "stopped",
3788 }
3789}
3790
3791fn buffer_pipe_stop_reason_label(reason: BufferPipeStopReason) -> &'static str {
3792 match reason {
3793 BufferPipeStopReason::Requested => "requested",
3794 BufferPipeStopReason::PipeExited => "pipe_exited",
3795 BufferPipeStopReason::WriteFailed => "write_failed",
3796 BufferPipeStopReason::BufferExited => "buffer_exited",
3797 BufferPipeStopReason::RuntimeInterrupted => "runtime_interrupted",
3798 }
3799}
3800
3801fn history_scope_label(scope: BufferHistoryScope) -> &'static str {
3802 match scope {
3803 BufferHistoryScope::Full => "full",
3804 BufferHistoryScope::Visible => "visible",
3805 }
3806}
3807
3808fn history_scope(scope: HistoryScopeArg) -> BufferHistoryScope {
3809 match scope {
3810 HistoryScopeArg::Full => BufferHistoryScope::Full,
3811 HistoryScopeArg::Visible => BufferHistoryScope::Visible,
3812 }
3813}
3814
3815fn history_placement(placement: HistoryPlacementArg) -> BufferHistoryPlacement {
3816 match placement {
3817 HistoryPlacementArg::Tab => BufferHistoryPlacement::Tab,
3818 HistoryPlacementArg::Floating => BufferHistoryPlacement::Floating,
3819 }
3820}
3821
3822fn ensure_history_attachment(
3823 buffer_id: BufferId,
3824 requested_placement: BufferHistoryPlacement,
3825 location: &BufferLocation,
3826) -> Result<()> {
3827 match (requested_placement, &location.attachment) {
3828 (BufferHistoryPlacement::Tab, BufferLocationAttachment::Session { .. })
3829 | (BufferHistoryPlacement::Floating, BufferLocationAttachment::Floating { .. }) => Ok(()),
3830 (_, BufferLocationAttachment::Detached) => Err(MuxError::protocol(format!(
3831 "buffer history returned detached helper location for buffer {buffer_id}"
3832 ))),
3833 (BufferHistoryPlacement::Tab, BufferLocationAttachment::Floating { .. }) => {
3834 Err(MuxError::protocol(format!(
3835 "buffer history returned unexpected attachment for buffer {buffer_id}: expected Tab"
3836 )))
3837 }
3838 (BufferHistoryPlacement::Floating, BufferLocationAttachment::Session { .. }) => {
3839 Err(MuxError::protocol(format!(
3840 "buffer history returned unexpected attachment for buffer {buffer_id}: expected Floating"
3841 )))
3842 }
3843 }
3844}
3845
3846fn ensure_history_response(
3847 source_buffer_id: BufferId,
3848 requested_scope: BufferHistoryScope,
3849 requested_placement: BufferHistoryPlacement,
3850 buffer: &embers_protocol::BufferRecord,
3851 location: &BufferLocation,
3852 at_root_tab: bool,
3853) -> Result<()> {
3854 if buffer.kind != embers_protocol::BufferRecordKind::Helper {
3855 return Err(MuxError::protocol(format!(
3856 "buffer history returned buffer {} with unexpected kind {:?}",
3857 buffer.id, buffer.kind
3858 )));
3859 }
3860 if buffer.helper_source_buffer_id != Some(source_buffer_id) {
3861 return Err(MuxError::protocol(format!(
3862 "buffer history returned helper {} for source {:?} instead of {source_buffer_id}",
3863 buffer.id, buffer.helper_source_buffer_id
3864 )));
3865 }
3866 if buffer.helper_scope != Some(requested_scope) {
3867 return Err(MuxError::protocol(format!(
3868 "buffer history returned helper {} with scope {:?} instead of {:?}",
3869 buffer.id, buffer.helper_scope, requested_scope
3870 )));
3871 }
3872
3873 let (session_id, node_id) = match &location.attachment {
3874 BufferLocationAttachment::Session {
3875 session_id,
3876 node_id,
3877 }
3878 | BufferLocationAttachment::Floating {
3879 session_id,
3880 node_id,
3881 ..
3882 } => (*session_id, *node_id),
3883 BufferLocationAttachment::Detached => {
3884 ensure_history_attachment(source_buffer_id, requested_placement, location)?;
3885 unreachable!("detached buffer history attachments should fail validation")
3886 }
3887 };
3888 if buffer.attachment_node_id != Some(node_id) {
3889 return Err(MuxError::protocol(format!(
3890 "buffer history returned helper {} attached to {:?} but location pointed at {node_id}",
3891 buffer.id, buffer.attachment_node_id
3892 )));
3893 }
3894
3895 ensure_history_attachment(source_buffer_id, requested_placement, location)?;
3896
3897 if matches!(requested_placement, BufferHistoryPlacement::Tab) && !at_root_tab {
3898 return Err(MuxError::protocol(format!(
3899 "buffer history returned helper node {node_id} outside session {session_id} root tabs"
3900 )));
3901 }
3902
3903 Ok(())
3904}
3905
3906fn break_destination(destination: BreakDestinationArg) -> NodeBreakDestination {
3907 match destination {
3908 BreakDestinationArg::Tab => NodeBreakDestination::Tab,
3909 BreakDestinationArg::Floating => NodeBreakDestination::Floating,
3910 }
3911}
3912
3913fn join_placement(placement: JoinPlacementArg) -> NodeJoinPlacement {
3914 match placement {
3915 JoinPlacementArg::Left => NodeJoinPlacement::Left,
3916 JoinPlacementArg::Right => NodeJoinPlacement::Right,
3917 JoinPlacementArg::Up => NodeJoinPlacement::Up,
3918 JoinPlacementArg::Down => NodeJoinPlacement::Down,
3919 JoinPlacementArg::TabBefore => NodeJoinPlacement::TabBefore,
3920 JoinPlacementArg::TabAfter => NodeJoinPlacement::TabAfter,
3921 }
3922}
3923
3924fn split_scoped_required(target: &str, label: &str) -> Result<(Option<String>, String)> {
3925 let (session, selector) = split_scoped_target(Some(target));
3926 let selector =
3927 selector.ok_or_else(|| MuxError::invalid_input(format!("{label} is required")))?;
3928 Ok((session, selector))
3929}
3930
3931fn parse_floating_id(raw: &str) -> Result<FloatingId> {
3932 raw.parse::<u64>().map(FloatingId).map_err(|_| {
3933 MuxError::invalid_input(format!("popup target '{raw}' is not a valid popup id"))
3934 })
3935}
3936
3937fn spawn_size(snapshot: &SessionSnapshot, placement: PanePlacement) -> Option<PtySize> {
3942 let viewport = snapshot.session.viewport?;
3943 let pane_titles = PaneTitles::reported(&snapshot.session);
3944 let mut state = ClientState::default();
3945 state.apply_session_snapshot(snapshot.clone());
3946 spawn_pty_size(
3947 &state,
3948 snapshot.session.id,
3949 viewport,
3950 pane_titles,
3951 placement,
3952 )
3953}
3954
3955fn buffer_command(command: Vec<String>) -> Vec<String> {
3956 if command.is_empty() {
3957 vec![std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned())]
3958 } else {
3959 command
3960 }
3961}
3962
3963fn default_title(command: &[String], fallback: &str) -> String {
3964 command
3965 .first()
3966 .and_then(|value| {
3967 Path::new(value)
3968 .file_name()
3969 .and_then(|name| name.to_str())
3970 .map(str::to_owned)
3971 })
3972 .unwrap_or_else(|| fallback.to_owned())
3973}
3974
3975#[cfg(test)]
3976mod tests {
3977 #[cfg(windows)]
3978 use base64::Engine as _;
3979 use clap::{Parser, error::ErrorKind};
3980 use embers_core::{ActivityState, BufferId, FloatingId, NodeId, PtySize, SessionId};
3981 use embers_protocol::{
3982 BufferLocation, BufferRecord, BufferRecordKind, BufferRecordState, TabRecord, TabsRecord,
3983 };
3984 #[cfg(windows)]
3985 use std::ffi::OsString;
3986 #[cfg(unix)]
3987 use std::ffi::OsString;
3988 #[cfg(unix)]
3989 use std::os::unix::ffi::OsStringExt;
3990 #[cfg(windows)]
3991 use std::os::windows::ffi::OsStringExt;
3992 use std::path::Path;
3993
3994 use super::{
3995 BreakDestinationArg, BufferCommand, Cli, Command, HistoryPlacementArg, HistoryScopeArg,
3996 JoinPlacementArg, NodeCommand, ensure_history_attachment, format_buffer_details,
3997 pane_buffer, resolve_window_index, split_scoped_required, split_scoped_target,
3998 };
3999
4000 #[test]
4001 fn pane_buffer_is_only_taken_for_the_panes_own_server() {
4002 let socket = Path::new("/run/embers/embers.sock");
4003 let same = Some(std::ffi::OsStr::new("/run/embers/embers.sock"));
4004 let other = Some(std::ffi::OsStr::new("/run/embers/other.sock"));
4005 assert_eq!(pane_buffer(Some("7"), same, socket), Some(BufferId(7)));
4006 assert_eq!(pane_buffer(Some("7"), other, socket), None);
4007 assert_eq!(pane_buffer(Some("7"), None, socket), None);
4008 assert_eq!(pane_buffer(None, same, socket), None);
4009 assert_eq!(pane_buffer(Some("%7"), same, socket), None);
4010 }
4011
4012 #[test]
4013 fn parser_accepts_global_socket_after_subcommand() {
4014 let cli = Cli::try_parse_from([
4015 "embers",
4016 "new-window",
4017 "--socket",
4018 "/tmp/mux.sock",
4019 "--title",
4020 "logs",
4021 "--",
4022 "/bin/sh",
4023 ])
4024 .expect("cli parses");
4025
4026 match cli.command {
4027 Some(super::Command::NewWindow { title, command, .. }) => {
4028 assert_eq!(title.as_deref(), Some("logs"));
4029 assert_eq!(command, vec!["/bin/sh"]);
4030 }
4031 other => panic!("expected new-window command, got {other:?}"),
4032 }
4033 }
4034
4035 #[test]
4036 fn runtime_keeper_uses_distinct_keeper_socket_flag() {
4037 let cli = Cli::try_parse_from([
4038 "embers",
4039 "__runtime-keeper",
4040 "--socket",
4041 "/tmp/global.sock",
4042 "--keeper-socket",
4043 "/tmp/keeper.sock",
4044 "--cols",
4045 "80",
4046 "--rows",
4047 "24",
4048 "--",
4049 "/bin/sh",
4050 ])
4051 .expect("cli parses");
4052
4053 assert_eq!(cli.socket.as_deref(), Some(Path::new("/tmp/global.sock")));
4054 match cli.command {
4055 Some(super::Command::RuntimeKeeper {
4056 keeper_socket,
4057 cols,
4058 rows,
4059 command,
4060 ..
4061 }) => {
4062 assert_eq!(keeper_socket, Path::new("/tmp/keeper.sock"));
4063 assert_eq!((cols, rows), (80, 24));
4064 assert_eq!(command, vec!["/bin/sh"]);
4065 }
4066 other => panic!("expected runtime keeper command, got {other:?}"),
4067 }
4068 }
4069
4070 #[cfg(unix)]
4071 #[test]
4072 fn runtime_keeper_env_values_decode_base64_losslessly() {
4073 let (key, value) = super::parse_env_arg("KEY=base64:AP8=").expect("env parses");
4074 assert_eq!(key, "KEY");
4075 assert_eq!(value, OsString::from_vec(vec![0, 255]));
4076 }
4077
4078 #[cfg(windows)]
4079 #[test]
4080 fn runtime_keeper_env_values_decode_utf16le_losslessly() {
4081 let encoded = base64::engine::general_purpose::STANDARD.encode([0x00, 0xD8, 0x61, 0x00]);
4082 let (key, value) =
4083 super::parse_env_arg(&format!("KEY=base64:{encoded}")).expect("env parses");
4084 assert_eq!(key, "KEY");
4085 assert_eq!(value, OsString::from_wide(&[0xD800, 0x0061]));
4086 }
4087
4088 #[test]
4089 fn scoped_targets_split_session_prefix() {
4090 assert_eq!(
4091 split_scoped_required("main:2", "window target").expect("target parses"),
4092 (Some("main".to_owned()), "2".to_owned())
4093 );
4094 assert_eq!(split_scoped_target(Some("3")), (None, Some("3".to_owned())));
4095 }
4096
4097 #[test]
4098 fn numeric_window_indices_report_ambiguity() {
4099 let tabs = TabsRecord {
4100 active: 0,
4101 tabs: vec![
4102 TabRecord {
4103 title: "one".to_owned(),
4104 child_id: NodeId(1),
4105 synchronize_panes: false,
4106 },
4107 TabRecord {
4108 title: "two".to_owned(),
4109 child_id: NodeId(2),
4110 synchronize_panes: false,
4111 },
4112 TabRecord {
4113 title: "three".to_owned(),
4114 child_id: NodeId(3),
4115 synchronize_panes: false,
4116 },
4117 ],
4118 };
4119
4120 let error = resolve_window_index(&tabs, Some("1"), None).expect_err("index is ambiguous");
4121 assert!(
4122 error
4123 .to_string()
4124 .contains("ambiguous between 0-based and 1-based")
4125 );
4126 assert_eq!(
4127 resolve_window_index(&tabs, Some("0"), None).expect("zero resolves"),
4128 0
4129 );
4130 }
4131
4132 #[test]
4133 fn numeric_window_indices_count_from_a_set_base_index() {
4134 let tabs = TabsRecord {
4135 active: 0,
4136 tabs: ["one", "two"]
4137 .into_iter()
4138 .enumerate()
4139 .map(|(index, title)| TabRecord {
4140 title: title.to_owned(),
4141 child_id: NodeId(index as u64 + 1),
4142 synchronize_panes: false,
4143 })
4144 .collect(),
4145 };
4146
4147 assert_eq!(resolve_window_index(&tabs, Some("1"), Some(1)).unwrap(), 0);
4148 assert_eq!(resolve_window_index(&tabs, Some("2"), Some(1)).unwrap(), 1);
4149 assert!(resolve_window_index(&tabs, Some("0"), Some(1)).is_err());
4150 assert_eq!(resolve_window_index(&tabs, Some("1"), Some(0)).unwrap(), 1);
4151 assert_eq!(
4152 resolve_window_index(&tabs, Some("two"), Some(1)).unwrap(),
4153 1
4154 );
4155 }
4156
4157 #[test]
4158 fn buffer_details_location_row_uses_single_tab_delimiter() {
4159 let details = format_buffer_details(
4160 &BufferRecord {
4161 id: BufferId(7),
4162 title: "logs".to_owned(),
4163 command: vec!["/bin/sh".to_owned()],
4164 cwd: Some("/tmp".to_owned()),
4165 kind: BufferRecordKind::Pty,
4166 pid: None,
4167 env: Default::default(),
4168 state: BufferRecordState::Running,
4169 attachment_node_id: Some(NodeId(3)),
4170 read_only: false,
4171 helper_source_buffer_id: None,
4172 helper_scope: None,
4173 pty_size: PtySize::new(80, 24),
4174 activity: ActivityState::Idle,
4175 last_snapshot_seq: 0,
4176 exit_code: None,
4177 pipe: None,
4178 user_options: Default::default(),
4179 foreground_command: None,
4180 },
4181 &BufferLocation::session(BufferId(7), SessionId(1), NodeId(3)),
4182 );
4183
4184 let location_line = details
4185 .lines()
4186 .find(|line| line.starts_with("location\t"))
4187 .expect("location row present");
4188 assert_eq!(location_line.matches('\t').count(), 1);
4189 }
4190
4191 #[test]
4192 fn buffer_details_command_row_preserves_argument_boundaries() {
4193 let details = format_buffer_details(
4194 &BufferRecord {
4195 id: BufferId(8),
4196 title: "script".to_owned(),
4197 command: vec![
4198 "/bin/sh".to_owned(),
4199 "-lc".to_owned(),
4200 "printf '%s\\n' 'hello world'".to_owned(),
4201 ],
4202 cwd: None,
4203 kind: BufferRecordKind::Pty,
4204 pid: None,
4205 env: Default::default(),
4206 state: BufferRecordState::Running,
4207 attachment_node_id: Some(NodeId(4)),
4208 read_only: false,
4209 helper_source_buffer_id: None,
4210 helper_scope: None,
4211 pty_size: PtySize::new(80, 24),
4212 activity: ActivityState::Idle,
4213 last_snapshot_seq: 0,
4214 exit_code: None,
4215 pipe: None,
4216 user_options: Default::default(),
4217 foreground_command: None,
4218 },
4219 &BufferLocation::session(BufferId(8), SessionId(1), NodeId(4)),
4220 );
4221
4222 let command_line = details
4223 .lines()
4224 .find(|line| line.starts_with("command\t"))
4225 .expect("command row present");
4226 let serialized_args = command_line
4227 .strip_prefix("command\t")
4228 .expect("command row prefix present");
4229 let command: Vec<String> =
4230 serde_json::from_str(serialized_args).expect("command row uses JSON");
4231 assert_eq!(
4232 command,
4233 vec![
4234 "/bin/sh".to_owned(),
4235 "-lc".to_owned(),
4236 "printf '%s\\n' 'hello world'".to_owned(),
4237 ]
4238 );
4239 }
4240
4241 #[test]
4242 fn buffer_details_show_the_foreground_command() {
4243 let buffer = |foreground_command: Option<Vec<String>>| BufferRecord {
4244 id: BufferId(10),
4245 title: "shell".to_owned(),
4246 command: vec!["/bin/zsh".to_owned()],
4247 cwd: None,
4248 kind: BufferRecordKind::Pty,
4249 pid: None,
4250 env: Default::default(),
4251 state: BufferRecordState::Running,
4252 attachment_node_id: Some(NodeId(6)),
4253 read_only: false,
4254 helper_source_buffer_id: None,
4255 helper_scope: None,
4256 pty_size: PtySize::new(80, 24),
4257 activity: ActivityState::Idle,
4258 last_snapshot_seq: 0,
4259 exit_code: None,
4260 pipe: None,
4261 user_options: Default::default(),
4262 foreground_command,
4263 };
4264 let location = BufferLocation::session(BufferId(10), SessionId(1), NodeId(6));
4265 let row = |details: &str, name: &str| {
4266 details
4267 .lines()
4268 .find_map(|line| line.strip_prefix(&format!("{name}\t")))
4269 .map(str::to_owned)
4270 };
4271
4272 let details = format_buffer_details(
4274 &buffer(Some(vec!["nvim".to_owned(), "notes.txt".to_owned()])),
4275 &location,
4276 );
4277 assert_eq!(
4278 row(&details, "current_command").as_deref(),
4279 Some("\"nvim\"")
4280 );
4281 let foreground: Vec<String> = serde_json::from_str(
4282 &row(&details, "foreground_command").expect("foreground_command row present"),
4283 )
4284 .expect("foreground_command row uses JSON");
4285 assert_eq!(foreground, ["nvim", "notes.txt"]);
4286
4287 let details = format_buffer_details(&buffer(None), &location);
4289 assert_eq!(row(&details, "current_command").as_deref(), Some("\"zsh\""));
4290 assert_eq!(row(&details, "foreground_command"), None);
4291 }
4292
4293 #[test]
4294 fn buffer_details_title_and_cwd_rows_use_json() {
4295 let details = format_buffer_details(
4296 &BufferRecord {
4297 id: BufferId(9),
4298 title: "build\tlogs\nstderr".to_owned(),
4299 command: Vec::new(),
4300 cwd: Some("/tmp/work\tspace\nhere".to_owned()),
4301 kind: BufferRecordKind::Pty,
4302 pid: None,
4303 env: Default::default(),
4304 state: BufferRecordState::Running,
4305 attachment_node_id: Some(NodeId(5)),
4306 read_only: false,
4307 helper_source_buffer_id: None,
4308 helper_scope: None,
4309 pty_size: PtySize::new(80, 24),
4310 activity: ActivityState::Idle,
4311 last_snapshot_seq: 0,
4312 exit_code: None,
4313 pipe: None,
4314 user_options: Default::default(),
4315 foreground_command: None,
4316 },
4317 &BufferLocation::session(BufferId(9), SessionId(1), NodeId(5)),
4318 );
4319
4320 let title_line = details
4321 .lines()
4322 .find(|line| line.starts_with("title\t"))
4323 .expect("title row present");
4324 let cwd_line = details
4325 .lines()
4326 .find(|line| line.starts_with("cwd\t"))
4327 .expect("cwd row present");
4328
4329 let title = title_line
4330 .strip_prefix("title\t")
4331 .expect("title row prefix present");
4332 let cwd = cwd_line
4333 .strip_prefix("cwd\t")
4334 .expect("cwd row prefix present");
4335
4336 assert_eq!(
4337 serde_json::from_str::<String>(title).expect("title row uses JSON"),
4338 "build\tlogs\nstderr"
4339 );
4340 assert_eq!(
4341 serde_json::from_str::<String>(cwd).expect("cwd row uses JSON"),
4342 "/tmp/work\tspace\nhere"
4343 );
4344 }
4345
4346 #[test]
4347 fn buffer_subcommands_parse_expected_flags_and_defaults() {
4348 let history =
4349 Cli::try_parse_from(["embers", "buffer", "history", "7"]).expect("history parses");
4350 match history.command {
4351 Some(Command::Buffer {
4352 command:
4353 BufferCommand::History {
4354 buffer_id,
4355 scope,
4356 placement,
4357 client,
4358 },
4359 }) => {
4360 assert_eq!(buffer_id, 7);
4361 assert!(matches!(scope, HistoryScopeArg::Full));
4362 assert!(matches!(placement, HistoryPlacementArg::Tab));
4363 assert_eq!(client, None);
4364 }
4365 other => panic!("expected buffer history command, got {other:?}"),
4366 }
4367
4368 let history_with_flags = Cli::try_parse_from([
4369 "embers",
4370 "buffer",
4371 "history",
4372 "9",
4373 "--scope",
4374 "visible",
4375 "--placement",
4376 "floating",
4377 "--client",
4378 "5",
4379 ])
4380 .expect("history flags parse");
4381 match history_with_flags.command {
4382 Some(Command::Buffer {
4383 command:
4384 BufferCommand::History {
4385 buffer_id,
4386 scope,
4387 placement,
4388 client,
4389 },
4390 }) => {
4391 assert_eq!(buffer_id, 9);
4392 assert!(matches!(scope, HistoryScopeArg::Visible));
4393 assert!(matches!(placement, HistoryPlacementArg::Floating));
4394 assert_eq!(client.map(std::num::NonZeroU64::get), Some(5));
4395 }
4396 other => panic!("expected flagged buffer history command, got {other:?}"),
4397 }
4398
4399 let reveal = Cli::try_parse_from(["embers", "buffer", "reveal", "11", "--client", "6"])
4400 .expect("reveal parses");
4401 match reveal.command {
4402 Some(Command::Buffer {
4403 command: BufferCommand::Reveal { buffer_id, client },
4404 }) => {
4405 assert_eq!(buffer_id, 11);
4406 assert_eq!(client.map(std::num::NonZeroU64::get), Some(6));
4407 }
4408 other => panic!("expected buffer reveal command, got {other:?}"),
4409 }
4410
4411 let set_option =
4412 Cli::try_parse_from(["embers", "buffer", "set-option", "-t", "p1", "is-vim", "1"])
4413 .expect("set-option parses");
4414 match set_option.command {
4415 Some(Command::Buffer {
4416 command:
4417 BufferCommand::SetOption {
4418 target,
4419 key,
4420 value,
4421 unset,
4422 },
4423 }) => {
4424 assert_eq!(target.as_deref(), Some("p1"));
4425 assert_eq!(key, "is-vim");
4426 assert_eq!(value.as_deref(), Some("1"));
4427 assert!(!unset);
4428 }
4429 other => panic!("expected buffer set-option command, got {other:?}"),
4430 }
4431
4432 let unset = Cli::try_parse_from(["embers", "buffer", "set-option", "--unset", "is-vim"])
4433 .expect("set-option --unset parses");
4434 match unset.command {
4435 Some(Command::Buffer {
4436 command:
4437 BufferCommand::SetOption {
4438 target,
4439 key,
4440 value,
4441 unset,
4442 },
4443 }) => {
4444 assert_eq!(target, None);
4445 assert_eq!(key, "is-vim");
4446 assert_eq!(value, None);
4447 assert!(unset);
4448 }
4449 other => panic!("expected buffer set-option --unset command, got {other:?}"),
4450 }
4451
4452 let no_value = Cli::try_parse_from(["embers", "buffer", "set-option", "is-vim"])
4455 .expect("set-option without a value still parses");
4456 assert!(matches!(
4457 no_value.command,
4458 Some(Command::Buffer {
4459 command: BufferCommand::SetOption {
4460 value: None,
4461 unset: false,
4462 ..
4463 },
4464 })
4465 ));
4466 assert!(
4467 Cli::try_parse_from(["embers", "buffer", "set-option", "--unset", "is-vim", "1"])
4468 .is_err(),
4469 "--unset combined with a value should be rejected"
4470 );
4471
4472 let show_options = Cli::try_parse_from(["embers", "buffer", "show-options", "-t", "p2"])
4473 .expect("show-options parses");
4474 match show_options.command {
4475 Some(Command::Buffer {
4476 command: BufferCommand::ShowOptions { target },
4477 }) => assert_eq!(target.as_deref(), Some("p2")),
4478 other => panic!("expected buffer show-options command, got {other:?}"),
4479 }
4480 let show_options_default = Cli::try_parse_from(["embers", "buffer", "show-options"])
4481 .expect("show-options without a target parses");
4482 assert!(matches!(
4483 show_options_default.command,
4484 Some(Command::Buffer {
4485 command: BufferCommand::ShowOptions { target: None },
4486 })
4487 ));
4488 }
4489
4490 #[test]
4491 fn history_attachment_validation_accepts_matching_locations() {
4492 assert!(
4493 ensure_history_attachment(
4494 BufferId(7),
4495 embers_protocol::BufferHistoryPlacement::Tab,
4496 &BufferLocation::session(BufferId(70), SessionId(1), NodeId(3)),
4497 )
4498 .is_ok()
4499 );
4500 assert!(
4501 ensure_history_attachment(
4502 BufferId(7),
4503 embers_protocol::BufferHistoryPlacement::Floating,
4504 &BufferLocation::floating(BufferId(71), SessionId(1), NodeId(4), FloatingId(5)),
4505 )
4506 .is_ok()
4507 );
4508 }
4509
4510 #[test]
4511 fn history_attachment_validation_rejects_mismatched_locations() {
4512 let floating_error = ensure_history_attachment(
4513 BufferId(7),
4514 embers_protocol::BufferHistoryPlacement::Floating,
4515 &BufferLocation::session(BufferId(70), SessionId(1), NodeId(3)),
4516 )
4517 .expect_err("floating history should reject tab helper");
4518 assert!(floating_error.to_string().contains("expected Floating"));
4519
4520 let tab_error = ensure_history_attachment(
4521 BufferId(7),
4522 embers_protocol::BufferHistoryPlacement::Tab,
4523 &BufferLocation::floating(BufferId(71), SessionId(1), NodeId(4), FloatingId(5)),
4524 )
4525 .expect_err("tab history should reject floating helper");
4526 assert!(tab_error.to_string().contains("expected Tab"));
4527 }
4528
4529 #[test]
4530 fn node_subcommands_parse_expected_flags_and_reject_legacy_shortcuts() {
4531 let break_to_floating =
4532 Cli::try_parse_from(["embers", "node", "break", "11", "--to", "floating"])
4533 .expect("break parses");
4534 match break_to_floating.command {
4535 Some(Command::Node {
4536 command:
4537 NodeCommand::Break {
4538 node_id,
4539 destination,
4540 },
4541 }) => {
4542 assert_eq!(node_id, 11);
4543 assert!(matches!(destination, BreakDestinationArg::Floating));
4544 }
4545 other => panic!("expected node break command, got {other:?}"),
4546 }
4547
4548 let join_after = Cli::try_parse_from([
4549 "embers",
4550 "node",
4551 "join-buffer",
4552 "21",
4553 "34",
4554 "--as",
4555 "tab-after",
4556 ])
4557 .expect("join-buffer tab-after parses");
4558 match join_after.command {
4559 Some(Command::Node {
4560 command:
4561 NodeCommand::JoinBuffer {
4562 node_id,
4563 buffer_id,
4564 placement,
4565 },
4566 }) => {
4567 assert_eq!(node_id, 21);
4568 assert_eq!(buffer_id, 34);
4569 assert!(matches!(placement, JoinPlacementArg::TabAfter));
4570 }
4571 other => panic!("expected node join-buffer command, got {other:?}"),
4572 }
4573
4574 let join_default = Cli::try_parse_from(["embers", "node", "join-buffer", "21", "34"])
4575 .expect("join-buffer default parses");
4576 match join_default.command {
4577 Some(Command::Node {
4578 command: NodeCommand::JoinBuffer { placement, .. },
4579 }) => {
4580 assert!(matches!(placement, JoinPlacementArg::TabAfter));
4581 }
4582 other => panic!("expected default node join-buffer command, got {other:?}"),
4583 }
4584
4585 let join_before = Cli::try_parse_from([
4586 "embers",
4587 "node",
4588 "join-buffer",
4589 "21",
4590 "34",
4591 "--as",
4592 "tab-before",
4593 ])
4594 .expect("join-buffer tab-before parses");
4595 match join_before.command {
4596 Some(Command::Node {
4597 command: NodeCommand::JoinBuffer { placement, .. },
4598 }) => {
4599 assert!(matches!(placement, JoinPlacementArg::TabBefore));
4600 }
4601 other => panic!("expected node join-buffer command, got {other:?}"),
4602 }
4603
4604 let legacy_tab_after =
4605 Cli::try_parse_from(["embers", "node", "join-buffer", "21", "34", "--tab-after"])
4606 .expect_err("legacy --tab-after should be rejected");
4607 assert_eq!(legacy_tab_after.kind(), ErrorKind::UnknownArgument);
4608
4609 let legacy_tab_before =
4610 Cli::try_parse_from(["embers", "node", "join-buffer", "21", "34", "--tab-before"])
4611 .expect_err("legacy --tab-before should be rejected");
4612 assert_eq!(legacy_tab_before.kind(), ErrorKind::UnknownArgument);
4613 }
4614}