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