mod automation;
mod capture;
mod clients;
mod formats;
mod interactive;
mod options;
mod popup;
mod sessions;
mod target;
use std::ffi::{OsStr, OsString};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::num::NonZeroU64;
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
#[cfg(windows)]
use std::os::windows::ffi::OsStringExt;
use std::path::{Path, PathBuf};
use std::process::{Command as ProcessCommand, Stdio};
use base64::Engine as _;
use clap::{Parser, Subcommand};
use embers_client::{
ClientState, CommandContext, CommandRunner, NavigationDirection, PanePlacement, PaneTitles,
PresentationModel, SplitResize, drawn_viewport, spawn_pty_size, window_number, window_position,
};
use embers_core::{
BufferId, FloatingId, MuxError, NodeId, PtySize, Result, SessionId, SplitDirection,
new_request_id,
};
use embers_protocol::{
BufferHistoryPlacement, BufferHistoryScope, BufferLocation, BufferLocationAttachment,
BufferLocationResponse, BufferPipeRecord, BufferPipeState, BufferPipeStopReason,
BufferRecordState, BufferRequest, BufferResponse, ClientMessage, ClientRecord, ClientRequest,
FloatingRecord, FloatingRequest, FloatingResponse, KillServerRequest, NodeBreakDestination,
NodeJoinPlacement, NodeRequest, PingRequest, ProtocolClient, ServerResponse, SessionRecord,
SessionRequest, SessionSnapshot, SnapshotResponse, SynchronizePanesMode, buffer_ids_in_subtree,
};
use embers_server::{BUFFER_ENV_VAR, SOCKET_ENV_VAR, Server, ServerConfig};
use tokio::time::{Duration, sleep};
use tracing::warn;
const DEFAULT_DISPLAY_MESSAGE: &str = "[#S] #I:#W, current pane #D - (#{pane_current_command})";
const DEFAULT_PANE_FORMAT: &str = "#{session_name}:#{window_index}.#{pane_index}";
const CLIENT_ENV_VAR: &str = "EMBERS_CLIENT";
pub const EMBERS_LOG_ENV_VAR: &str = "EMBERS_LOG";
#[derive(Debug, Parser)]
#[command(name = "embers", about = "headless terminal multiplexer for embers")]
pub struct Cli {
#[arg(long, global = true)]
pub socket: Option<PathBuf>,
#[arg(long, global = true)]
pub config: Option<PathBuf>,
#[arg(
long,
visible_alias = "log-level",
global = true,
value_name = "FILTER"
)]
pub log: Option<String>,
#[arg(short = 'v', action = clap::ArgAction::Count)]
pub verbose_short: u8,
#[arg(long = "verbose", global = true, action = clap::ArgAction::Count, hide = true)]
pub verbose: u8,
#[arg(long = "base-index", global = true, value_name = "N", value_parser = parse_base_index_arg)]
pub base_index: Option<u32>,
#[command(subcommand)]
pub command: Option<Command>,
}
impl Cli {
pub fn log_filter(&self) -> String {
if let Some(filter) = self.log.as_ref().filter(|value| !value.trim().is_empty()) {
return filter.clone();
}
match self.verbose.saturating_add(self.verbose_short) {
0 => {}
1 => return "debug".to_owned(),
_ => return "trace".to_owned(),
}
if let Some(filter) = std::env::var(EMBERS_LOG_ENV_VAR)
.ok()
.filter(|value| !value.trim().is_empty())
{
return filter;
}
if let Some(filter) = std::env::var("RUST_LOG")
.ok()
.filter(|value| !value.trim().is_empty())
{
return filter;
}
"info".to_owned()
}
pub fn base_index(&self) -> Option<u32> {
self.base_index.or_else(embers_client::base_index_from_env)
}
}
fn parse_base_index_arg(value: &str) -> std::result::Result<u32, String> {
embers_client::parse_base_index(value).ok_or_else(|| {
format!(
"expected a number from 0 to {}",
embers_client::numbering::MAX_BASE_INDEX
)
})
}
#[derive(Debug, Subcommand)]
pub enum Command {
Attach {
#[arg(short = 't', long = "target")]
target: Option<String>,
},
#[command(name = "__serve", hide = true)]
Serve,
#[command(name = "__runtime-keeper", hide = true)]
RuntimeKeeper {
#[arg(long = "keeper-socket")]
keeper_socket: PathBuf,
#[arg(long)]
cols: u16,
#[arg(long)]
rows: u16,
#[arg(long = "pixel-width", default_value_t = 0)]
pixel_width: u16,
#[arg(long = "pixel-height", default_value_t = 0)]
pixel_height: u16,
#[arg(long)]
cwd: Option<PathBuf>,
#[arg(long = "env", value_parser = parse_env_arg)]
env: Vec<(String, OsString)>,
#[arg(last = true)]
command: Vec<String>,
},
Automation {
#[arg(short = 't', long = "target", conflicts_with = "all_sessions")]
target: Option<String>,
#[arg(long, conflicts_with = "target")]
all_sessions: bool,
},
Ping {
#[arg(default_value = "phase0")]
payload: String,
},
#[command(name = "kill-server")]
KillServer,
#[command(name = "new-session", visible_alias = "new")]
NewSession {
#[arg(
value_name = "NAME",
conflicts_with_all = ["session_name", "attach_existing", "cwd", "window_name", "width", "height", "print", "format", "command"]
)]
name: Option<String>,
#[arg(short = 'd')]
detached: bool,
#[arg(short = 'A')]
attach_existing: bool,
#[arg(short = 's', value_name = "NAME")]
session_name: Option<String>,
#[arg(short = 'n', value_name = "WINDOW")]
window_name: Option<String>,
#[arg(short = 'c', value_name = "DIR")]
cwd: Option<String>,
#[arg(short = 'x', value_name = "COLUMNS")]
width: Option<u16>,
#[arg(short = 'y', value_name = "ROWS")]
height: Option<u16>,
#[arg(short = 'P')]
print: bool,
#[arg(short = 'F', value_name = "FORMAT", requires = "print")]
format: Option<String>,
#[arg(last = true)]
command: Vec<String>,
},
#[command(name = "list-sessions", visible_alias = "ls")]
ListSessions {
#[arg(short = 'F', value_name = "FORMAT")]
format: Option<String>,
},
#[command(name = "has-session")]
HasSession {
#[arg(short = 't', long = "target")]
target: String,
},
#[command(name = "kill-session")]
KillSession {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
detach: bool,
},
#[command(name = "rename-session")]
RenameSession {
#[arg(short = 't', long = "target")]
target: Option<String>,
name: String,
},
#[command(name = "list-buffers")]
ListBuffers {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(long, conflicts_with = "detached")]
attached: bool,
#[arg(long, conflicts_with = "attached")]
detached: bool,
},
#[command(name = "attach-buffer")]
AttachBuffer {
buffer_id: u64,
#[arg(short = 't', long = "target")]
target: Option<String>,
},
#[command(name = "list-clients", visible_alias = "lsc")]
ListClients {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(short = 'F', value_name = "FORMAT")]
format: Option<String>,
},
#[command(name = "detach-client", visible_alias = "detach")]
DetachClient {
#[arg(conflicts_with = "client")]
client_id: Option<u64>,
#[arg(short = 'c', long = "client", value_name = "CLIENT")]
client: Option<u64>,
},
#[command(name = "switch-client", visible_alias = "switchc")]
SwitchClient {
#[arg(conflicts_with = "client")]
client_id: Option<u64>,
#[arg(short = 'c', long = "client", value_name = "CLIENT")]
client: Option<u64>,
#[arg(
short = 't',
long = "target",
required_unless_present_any = ["next", "previous"],
conflicts_with_all = ["next", "previous"]
)]
target: Option<String>,
#[arg(short = 'n', conflicts_with = "previous")]
next: bool,
#[arg(short = 'p')]
previous: bool,
},
Buffer {
#[command(subcommand)]
command: BufferCommand,
},
Node {
#[command(subcommand)]
command: NodeCommand,
},
#[command(name = "new-window", visible_alias = "neww")]
NewWindow {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(short = 'n', long)]
title: Option<String>,
#[arg(short = 'c', value_name = "DIR")]
cwd: Option<String>,
#[arg(short = 'd')]
detached: bool,
#[arg(short = 'P')]
print: bool,
#[arg(short = 'F', value_name = "FORMAT", requires = "print")]
format: Option<String>,
#[arg(last = true)]
command: Vec<String>,
},
#[command(name = "list-windows", visible_alias = "lsw")]
ListWindows {
#[arg(short = 't', long = "target", conflicts_with = "all")]
target: Option<String>,
#[arg(short = 'a')]
all: bool,
#[arg(short = 'F', value_name = "FORMAT")]
format: Option<String>,
},
#[command(name = "select-window")]
SelectWindow {
#[arg(short = 't', long = "target")]
target: String,
},
#[command(name = "rename-window")]
RenameWindow {
#[arg(short = 't', long = "target")]
target: Option<String>,
title: String,
},
#[command(name = "kill-window")]
KillWindow {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(long)]
detach: bool,
},
#[command(
name = "split-window",
visible_alias = "splitw",
disable_help_flag = true
)]
SplitWindow {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(short = 'h', group = "split_direction")]
side_by_side: bool,
#[arg(short = 'v', group = "split_direction")]
stacked: bool,
#[arg(long, group = "split_direction")]
horizontal: bool,
#[arg(long, group = "split_direction")]
vertical: bool,
#[arg(short = 'c', value_name = "DIR")]
cwd: Option<String>,
#[arg(short = 'd')]
detached: bool,
#[arg(short = 'P')]
print: bool,
#[arg(short = 'F', value_name = "FORMAT", requires = "print")]
format: Option<String>,
#[arg(long, action = clap::ArgAction::Help)]
help: Option<bool>,
#[arg(last = true)]
command: Vec<String>,
},
#[command(name = "list-panes", visible_alias = "lsp")]
ListPanes {
#[arg(short = 't', long = "target", conflicts_with = "all")]
target: Option<String>,
#[arg(short = 's', conflicts_with = "all")]
session: bool,
#[arg(short = 'a')]
all: bool,
#[arg(short = 'F', value_name = "FORMAT")]
format: Option<String>,
},
#[command(name = "select-pane", visible_alias = "selectp")]
SelectPane {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(short = 'l', long = "last", conflicts_with_all = ["target", "pane_direction"])]
last: bool,
#[arg(short = 'L', group = "pane_direction")]
left: bool,
#[arg(short = 'R', group = "pane_direction")]
right: bool,
#[arg(short = 'U', group = "pane_direction")]
up: bool,
#[arg(short = 'D', group = "pane_direction")]
down: bool,
},
#[command(name = "set-option", visible_alias = "set")]
SetOption {
#[arg(short = 'p')]
pane: bool,
#[arg(short = 'w', hide = true)]
window: bool,
#[arg(short = 's', hide = true)]
server: bool,
#[arg(short = 'g', hide = true)]
global: bool,
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(short = 'u')]
unset: bool,
#[arg(short = 'o')]
only_if_unset: bool,
#[arg(short = 'q')]
quiet: bool,
name: String,
#[arg(conflicts_with = "unset")]
value: Option<String>,
},
#[command(name = "show-options", visible_alias = "show")]
ShowOptions {
#[arg(short = 'p')]
pane: bool,
#[arg(short = 'w', hide = true)]
window: bool,
#[arg(short = 's', hide = true)]
server: bool,
#[arg(short = 'g', hide = true)]
global: bool,
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(short = 'v')]
value_only: bool,
#[arg(short = 'q')]
quiet: bool,
name: Option<String>,
},
#[command(name = "resize-pane")]
ResizePane {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(long, value_delimiter = ',', conflicts_with_all = ["direction", "zoom"])]
sizes: Vec<u16>,
#[arg(short = 'L', group = "direction")]
left: bool,
#[arg(short = 'R', group = "direction")]
right: bool,
#[arg(short = 'U', group = "direction")]
up: bool,
#[arg(short = 'D', group = "direction")]
down: bool,
#[arg(short = 'Z', conflicts_with = "direction")]
zoom: bool,
#[arg(requires = "direction")]
adjustment: Option<u16>,
},
#[command(name = "synchronize-panes")]
SynchronizePanes {
#[arg(short = 't', long = "target")]
target: Option<String>,
state: Option<SwitchArg>,
},
#[command(name = "send-keys")]
SendKeys {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(long)]
enter: bool,
keys: Vec<String>,
},
#[command(name = "capture-pane", visible_alias = "capturep")]
CapturePane {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(short = 'p')]
print: bool,
#[arg(short = 'S', value_name = "LINE", allow_hyphen_values = true, value_parser = capture::parse_line_bound)]
start: Option<capture::LineBound>,
#[arg(short = 'E', value_name = "LINE", allow_hyphen_values = true, value_parser = capture::parse_line_bound)]
end: Option<capture::LineBound>,
#[arg(short = 'J')]
join: bool,
#[arg(short = 'e')]
escapes: bool,
},
#[command(name = "kill-pane")]
KillPane {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(long)]
detach: bool,
},
#[command(
name = "display-popup",
visible_alias = "popup",
disable_help_flag = true
)]
DisplayPopup {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(short = 'T', long)]
title: Option<String>,
#[arg(short = 'x', long = "x", value_parser = popup::parse_popup_position)]
x: Option<popup::PopupPosition>,
#[arg(short = 'y', long = "y", value_parser = popup::parse_popup_position)]
y: Option<popup::PopupPosition>,
#[arg(short = 'w', long = "width", value_parser = popup::parse_popup_length)]
width: Option<popup::PopupLength>,
#[arg(short = 'h', long = "height", value_parser = popup::parse_popup_length)]
height: Option<popup::PopupLength>,
#[arg(short = 'E', action = clap::ArgAction::Count)]
close_on_exit: u8,
#[arg(short = 'd', short_alias = 'c', long = "cwd", value_name = "DIR")]
cwd: Option<String>,
#[arg(short = 'P')]
print: bool,
#[arg(short = 'F', value_name = "FORMAT", requires = "print")]
format: Option<String>,
#[arg(long)]
no_wait: bool,
#[arg(short = 'B', long = "no-border")]
no_border: bool,
#[arg(long, action = clap::ArgAction::Help)]
help: Option<bool>,
#[arg(last = true)]
command: Vec<String>,
},
#[command(name = "kill-popup")]
KillPopup {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(long)]
detach: bool,
},
#[command(name = "display-message", visible_alias = "display")]
DisplayMessage {
#[arg(short = 't', long = "target")]
target: Option<String>,
#[arg(short = 'c', long = "client")]
client: Option<NonZeroU64>,
#[arg(short = 'p', long = "print")]
print: bool,
#[arg(short = 'd', long = "duration", value_name = "MS")]
duration: Option<u32>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
message: Vec<String>,
},
#[command(name = "set-environment", visible_alias = "setenv")]
SetEnvironment {
#[arg(short = 't', long = "target", conflicts_with = "global")]
target: Option<String>,
#[arg(short = 'g', long = "global")]
global: bool,
#[arg(short = 'u', long = "unset")]
unset: bool,
name: String,
#[arg(conflicts_with = "unset", allow_hyphen_values = true)]
value: Option<String>,
},
#[command(name = "show-environment", visible_alias = "showenv")]
ShowEnvironment {
#[arg(short = 't', long = "target", conflicts_with = "global")]
target: Option<String>,
#[arg(short = 'g', long = "global")]
global: bool,
name: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum BufferCommand {
Show {
buffer_id: u64,
},
Pipe {
#[command(subcommand)]
command: BufferPipeCommand,
},
Reveal {
buffer_id: u64,
#[arg(long)]
client: Option<NonZeroU64>,
},
History {
buffer_id: u64,
#[arg(long, default_value = "full")]
scope: HistoryScopeArg,
#[arg(long, default_value = "tab")]
placement: HistoryPlacementArg,
#[arg(long)]
client: Option<NonZeroU64>,
},
SetOption {
#[arg(short = 't', long = "target")]
target: Option<String>,
key: String,
value: Option<String>,
#[arg(long, conflicts_with = "value")]
unset: bool,
},
ShowOptions {
#[arg(short = 't', long = "target")]
target: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum BufferPipeCommand {
Start {
buffer_id: u64,
#[arg(long)]
cwd: Option<String>,
#[arg(long = "env", value_parser = parse_string_env_arg)]
env: Vec<(String, String)>,
#[arg(last = true)]
command: Vec<String>,
},
Stop {
buffer_id: u64,
},
Show {
buffer_id: u64,
},
}
#[derive(Debug, Subcommand)]
pub enum NodeCommand {
Zoom {
node_id: u64,
},
Unzoom {
#[arg(short = 't', long = "target")]
target: Option<String>,
},
ToggleZoom {
node_id: u64,
},
Swap {
first_node_id: u64,
second_node_id: u64,
},
Break {
node_id: u64,
#[arg(long = "to")]
destination: BreakDestinationArg,
},
JoinBuffer {
node_id: u64,
buffer_id: u64,
#[arg(long = "as", default_value = "tab-after")]
placement: JoinPlacementArg,
},
MoveBefore {
node_id: u64,
sibling_id: u64,
},
MoveAfter {
node_id: u64,
sibling_id: u64,
},
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
pub enum SwitchArg {
On,
Off,
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
pub enum HistoryScopeArg {
Full,
Visible,
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
pub enum HistoryPlacementArg {
Tab,
Floating,
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
pub enum BreakDestinationArg {
Tab,
Floating,
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
pub enum JoinPlacementArg {
Left,
Right,
Up,
Down,
#[value(name = "tab-before")]
TabBefore,
#[value(name = "tab-after")]
TabAfter,
}
struct Outcome {
output: String,
attach_session: Option<String>,
exit_status: Option<i32>,
}
async fn execute(socket: &Path, command: Command, base_index: Option<u32>) -> Result<Outcome> {
use std::io::IsTerminal as _;
let mut connection = CliConnection::connect(socket).await?;
connection.base_index = base_index;
let pane_socket = std::env::var_os(SOCKET_ENV_VAR);
connection.pane_buffer = pane_buffer(
std::env::var(BUFFER_ENV_VAR).ok().as_deref(),
pane_socket.as_deref(),
socket,
);
connection.caller_client = pane_buffer(
std::env::var(CLIENT_ENV_VAR).ok().as_deref(),
pane_socket.as_deref(),
socket,
)
.and_then(|client| NonZeroU64::new(client.0));
connection.working_dir = std::env::current_dir()
.ok()
.map(|dir| dir.to_string_lossy().into_owned());
connection.wait_for_popups = true;
connection.can_attach = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
let output = execute_command(&mut connection, command).await?;
Ok(Outcome {
output,
attach_session: connection.attach_session,
exit_status: connection.exit_status,
})
}
async fn execute_command(connection: &mut CliConnection, command: Command) -> Result<String> {
match command {
Command::Attach { .. }
| Command::Serve
| Command::RuntimeKeeper { .. }
| Command::Automation { .. } => Err(MuxError::invalid_input(
"interactive commands must be dispatched through run()",
)),
Command::Ping { payload } => {
let response = connection
.request(ClientMessage::Ping(PingRequest {
request_id: new_request_id(),
payload,
}))
.await?;
match response {
ServerResponse::Pong(response) => Ok(format!("pong {}", response.payload)),
other => Err(MuxError::protocol(format!(
"unexpected response to ping request: {other:?}"
))),
}
}
Command::KillServer => {
connection
.request(ClientMessage::KillServer(KillServerRequest {
request_id: new_request_id(),
}))
.await?;
Ok(String::new())
}
Command::NewSession {
name: Some(name), ..
} => {
let response = connection
.request(ClientMessage::Session(SessionRequest::Create {
request_id: new_request_id(),
name,
}))
.await?;
let snapshot = expect_session_snapshot(response, "new-session")?;
Ok(format!(
"{}\t{}",
snapshot.session.id, snapshot.session.name
))
}
Command::NewSession {
name: None,
detached,
attach_existing,
session_name,
window_name,
cwd,
width,
height,
print,
format,
command,
} => {
connection
.new_session(sessions::NewSession {
detached,
attach_existing,
name: session_name,
window_name,
cwd,
width,
height,
format: print.then(|| format.unwrap_or_else(|| "#{session_name}:".to_owned())),
command,
})
.await
}
Command::ListSessions { format } => {
let sessions = connection.list_sessions().await?;
let Some(format) = format else {
return Ok(format_sessions(&sessions));
};
let mut lines = Vec::new();
for session in sessions {
let snapshot = connection.session_snapshot(session.id).await?;
lines.push(connection.expand_for_pane(&snapshot, None, &format).await?);
}
Ok(lines.join("\n"))
}
Command::HasSession { target } => {
connection.resolve_session_record(Some(&target)).await?;
Ok(String::new())
}
Command::KillSession {
target,
force,
detach,
} => {
let snapshot = connection
.resolve_session_snapshot(target.as_deref())
.await?;
let shown = snapshot
.buffers
.iter()
.map(|buffer| buffer.id)
.collect::<Vec<_>>();
connection
.request(ClientMessage::Session(SessionRequest::Close {
request_id: new_request_id(),
session_id: snapshot.session.id,
force,
}))
.await?;
if !detach {
connection.kill_detached_buffers(shown).await?;
}
Ok(String::new())
}
Command::RenameSession { target, name } => {
let session = connection.resolve_session_record(target.as_deref()).await?;
connection
.request(ClientMessage::Session(SessionRequest::Rename {
request_id: new_request_id(),
session_id: session.id,
name,
}))
.await?;
Ok(String::new())
}
Command::ListBuffers {
target,
attached,
detached,
} => {
let session_id = match target {
Some(target) => Some(connection.resolve_session_record(Some(&target)).await?.id),
None => None,
};
let response = connection
.request(ClientMessage::Buffer(BufferRequest::List {
request_id: new_request_id(),
session_id,
attached_only: attached,
detached_only: detached,
}))
.await?;
match response {
ServerResponse::Buffers(response) => Ok(format_buffers(&response.buffers)),
other => Err(MuxError::protocol(format!(
"unexpected response to list-buffers: {other:?}"
))),
}
}
Command::AttachBuffer { buffer_id, target } => {
let pane = connection.resolve_pane(target.as_deref()).await?;
let response = connection
.request(ClientMessage::Node(NodeRequest::MoveBufferToNode {
request_id: new_request_id(),
buffer_id: BufferId(buffer_id),
target_leaf_node_id: pane.leaf_id,
}))
.await?;
expect_session_snapshot(response, "attach-buffer")?;
Ok(String::new())
}
Command::ListClients { target, format } => {
let sessions = connection.list_sessions().await?;
let mut clients = connection.list_clients().await?;
if let Some(target) = target {
let session = connection.resolve_session_record(Some(&target)).await?;
clients.retain(|client| client.current_session_id == Some(session.id));
}
let Some(format) = format else {
return Ok(format_clients(&clients, &sessions));
};
let mut lines = Vec::new();
for client in clients {
let session_name = client
.current_session_id
.and_then(|id| sessions.iter().find(|session| session.id == id))
.map(|session| session.name.clone())
.unwrap_or_default();
let extra = std::collections::BTreeMap::from([
("client_name".to_owned(), client.id.to_string()),
("client_id".to_owned(), client.id.to_string()),
("client_session".to_owned(), session_name),
]);
let line = match client.current_session_id {
Some(session_id) => {
let snapshot = connection.session_snapshot(session_id).await?;
connection
.expand_for_pane_with(&snapshot, None, &format, extra)
.await?
}
None => embers_client::expand_format(
&format,
&embers_client::FormatContext {
extra,
..embers_client::FormatContext::default()
},
),
};
lines.push(line);
}
Ok(lines.join("\n"))
}
Command::DetachClient { client_id, client } => {
let client_id = connection.target_client(client_id.or(client)).await?;
connection
.request(ClientMessage::Client(ClientRequest::Detach {
request_id: new_request_id(),
client_id: Some(client_id),
}))
.await?;
Ok(String::new())
}
Command::SwitchClient {
client_id,
client,
target,
next: _,
previous,
} => {
let client_id = connection.target_client(client_id.or(client)).await?;
let (session_id, node_id) = match target {
Some(target) => connection.switch_target(&target).await?,
None => (
connection.neighbour_session(client_id, !previous).await?.id,
None,
),
};
match connection
.request(ClientMessage::Client(ClientRequest::Switch {
request_id: new_request_id(),
client_id: Some(client_id),
session_id,
node_id,
}))
.await?
{
ServerResponse::Client(_) => Ok(String::new()),
other => Err(MuxError::protocol(format!(
"unexpected response to switch-client: {other:?}"
))),
}
}
Command::Buffer { command } => match command {
BufferCommand::Show { buffer_id } => {
let requested_buffer_id = BufferId(buffer_id);
let response = connection
.request(ClientMessage::Buffer(BufferRequest::Inspect {
request_id: new_request_id(),
buffer_id: requested_buffer_id,
}))
.await?;
let (buffer, location, _) = expect_buffer_with_location(response, "buffer show")?;
ensure_matching_buffer_id("buffer show", requested_buffer_id, buffer.id)?;
Ok(format_buffer_details(&buffer, &location))
}
BufferCommand::Pipe { command } => match command {
BufferPipeCommand::Start {
buffer_id,
cwd,
env,
command,
} => {
if command.is_empty() {
return Err(MuxError::invalid_input(
"buffer pipe command must not be empty",
));
}
let expected = BufferId(buffer_id);
let response = connection
.request(ClientMessage::Buffer(BufferRequest::StartPipe {
request_id: new_request_id(),
buffer_id: expected,
command,
cwd,
env: env.into_iter().collect(),
}))
.await?;
match response {
ServerResponse::Buffer(response) => {
ensure_matching_buffer_id(
"buffer pipe start",
expected,
response.buffer.id,
)?;
format_buffer_pipe_details(
response.buffer.id,
response.buffer.pipe.as_ref(),
)
}
other => Err(MuxError::protocol(format!(
"unexpected response to buffer pipe start: {other:?}"
))),
}
}
BufferPipeCommand::Stop { buffer_id } => {
let expected = BufferId(buffer_id);
let response = connection
.request(ClientMessage::Buffer(BufferRequest::StopPipe {
request_id: new_request_id(),
buffer_id: expected,
}))
.await?;
match response {
ServerResponse::Buffer(response) => {
ensure_matching_buffer_id(
"buffer pipe stop",
expected,
response.buffer.id,
)?;
format_buffer_pipe_details(
response.buffer.id,
response.buffer.pipe.as_ref(),
)
}
other => Err(MuxError::protocol(format!(
"unexpected response to buffer pipe stop: {other:?}"
))),
}
}
BufferPipeCommand::Show { buffer_id } => {
let expected = BufferId(buffer_id);
let response = connection
.request(ClientMessage::Buffer(BufferRequest::Get {
request_id: new_request_id(),
buffer_id: expected,
}))
.await?;
match response {
ServerResponse::Buffer(response) => {
ensure_matching_buffer_id(
"buffer pipe show",
expected,
response.buffer.id,
)?;
format_buffer_pipe_details(
response.buffer.id,
response.buffer.pipe.as_ref(),
)
}
other => Err(MuxError::protocol(format!(
"unexpected response to buffer pipe show: {other:?}"
))),
}
}
},
BufferCommand::Reveal { buffer_id, client } => {
let requested_buffer_id = BufferId(buffer_id);
let response = connection
.request(ClientMessage::Buffer(BufferRequest::Reveal {
request_id: new_request_id(),
buffer_id: requested_buffer_id,
client_id: client,
}))
.await?;
let location = expect_buffer_location(response, "buffer reveal")?;
ensure_matching_buffer_id(
"buffer reveal",
requested_buffer_id,
location.buffer_id,
)?;
if matches!(location.attachment, BufferLocationAttachment::Detached) {
return Err(MuxError::conflict(format!(
"buffer {} is detached; use attach-buffer or node join-buffer",
buffer_id
)));
}
Ok(format_buffer_location_line(&location))
}
BufferCommand::History {
buffer_id,
scope,
placement,
client,
} => {
let requested_scope = history_scope(scope);
let requested_placement = history_placement(placement);
let response = connection
.request(ClientMessage::Buffer(BufferRequest::OpenHistory {
request_id: new_request_id(),
buffer_id: BufferId(buffer_id),
scope: requested_scope,
placement: requested_placement,
client_id: client,
}))
.await?;
let (buffer, location, at_root_tab) =
expect_buffer_with_location(response, "buffer history")?;
ensure_history_response(
BufferId(buffer_id),
requested_scope,
requested_placement,
&buffer,
&location,
at_root_tab,
)?;
Ok(format_buffer_location_line(&location))
}
BufferCommand::SetOption {
target,
key,
value,
unset,
} => {
let buffer_id = connection.resolve_buffer(target.as_deref()).await?;
let value = if unset {
None
} else {
Some(value.ok_or_else(|| {
MuxError::invalid_input(
"buffer set-option requires a value (or --unset to clear it)",
)
})?)
};
connection
.set_buffer_option(buffer_id, key, value, "buffer set-option")
.await?;
Ok(String::new())
}
BufferCommand::ShowOptions { target } => {
let buffer_id = connection.resolve_buffer(target.as_deref()).await?;
Ok(connection
.buffer_options(buffer_id, "buffer show-options")
.await?
.iter()
.map(|(key, value)| {
format!(
"{}\t{}",
serde_json::to_string(key).expect("user option keys serialize to JSON"),
serde_json::to_string(value)
.expect("user option values serialize to JSON"),
)
})
.collect::<Vec<_>>()
.join("\n"))
}
},
Command::Node { command } => match command {
NodeCommand::Zoom { node_id } => {
let response = connection
.request(ClientMessage::Node(NodeRequest::Zoom {
request_id: new_request_id(),
node_id: NodeId(node_id),
}))
.await?;
expect_ok(response, "NodeCommand::Zoom")?;
Ok(String::new())
}
NodeCommand::Unzoom { target } => {
let session = connection.resolve_session_record(target.as_deref()).await?;
let response = connection
.request(ClientMessage::Node(NodeRequest::Unzoom {
request_id: new_request_id(),
session_id: session.id,
}))
.await?;
expect_ok(response, "NodeCommand::Unzoom")?;
Ok(String::new())
}
NodeCommand::ToggleZoom { node_id } => {
let response = connection
.request(ClientMessage::Node(NodeRequest::ToggleZoom {
request_id: new_request_id(),
node_id: NodeId(node_id),
}))
.await?;
expect_ok(response, "NodeCommand::ToggleZoom")?;
Ok(String::new())
}
NodeCommand::Swap {
first_node_id,
second_node_id,
} => {
let response = connection
.request(ClientMessage::Node(NodeRequest::SwapSiblings {
request_id: new_request_id(),
first_node_id: NodeId(first_node_id),
second_node_id: NodeId(second_node_id),
}))
.await?;
expect_ok(response, "NodeCommand::Swap")?;
Ok(String::new())
}
NodeCommand::Break {
node_id,
destination,
} => {
let response = connection
.request(ClientMessage::Node(NodeRequest::BreakNode {
request_id: new_request_id(),
node_id: NodeId(node_id),
destination: break_destination(destination),
}))
.await?;
expect_ok(response, "NodeCommand::Break")?;
Ok(String::new())
}
NodeCommand::JoinBuffer {
node_id,
buffer_id,
placement,
} => {
let response = connection
.request(ClientMessage::Node(NodeRequest::JoinBufferAtNode {
request_id: new_request_id(),
node_id: NodeId(node_id),
buffer_id: BufferId(buffer_id),
placement: join_placement(placement),
}))
.await?;
expect_ok(response, "NodeCommand::JoinBuffer")?;
Ok(String::new())
}
NodeCommand::MoveBefore {
node_id,
sibling_id,
} => {
let response = connection
.request(ClientMessage::Node(NodeRequest::MoveNodeBefore {
request_id: new_request_id(),
node_id: NodeId(node_id),
sibling_node_id: NodeId(sibling_id),
}))
.await?;
expect_ok(response, "NodeCommand::MoveBefore")?;
Ok(String::new())
}
NodeCommand::MoveAfter {
node_id,
sibling_id,
} => {
let response = connection
.request(ClientMessage::Node(NodeRequest::MoveNodeAfter {
request_id: new_request_id(),
node_id: NodeId(node_id),
sibling_node_id: NodeId(sibling_id),
}))
.await?;
expect_ok(response, "NodeCommand::MoveAfter")?;
Ok(String::new())
}
},
Command::NewWindow {
target,
title,
cwd,
detached,
print,
format,
command,
} => {
let session = connection.resolve_session_record(target.as_deref()).await?;
let command = buffer_command(command);
let window_title = title.unwrap_or_else(|| default_title(&command, "window"));
let snapshot = connection.session_snapshot(session.id).await?;
let cwd = connection
.spawn_cwd(cwd.as_deref(), Some(&snapshot))
.await?;
let previous_window = current_root_window(&snapshot)?;
let previous_focus = snapshot.session.focused_leaf_id;
let size = spawn_size(&snapshot, PanePlacement::RootTab);
let buffer = connection
.create_buffer(Some(window_title.clone()), command, cwd, size, session.id)
.await?;
let response = connection
.request(ClientMessage::Session(SessionRequest::AddRootTab {
request_id: new_request_id(),
session_id: session.id,
title: window_title.clone(),
buffer_id: Some(buffer.buffer.id),
child_node_id: None,
}))
.await;
let response = rollback_created_buffer_on_error(
connection,
buffer.buffer.id,
"new-window",
response,
)
.await?;
let mut snapshot = rollback_created_buffer_on_error(
connection,
buffer.buffer.id,
"new-window",
expect_session_snapshot(response, "new-window"),
)
.await?;
let (index, title) = active_root_window(&snapshot)?;
let number = window_number(index as usize, connection.base_index_for(&snapshot));
if detached {
snapshot = restore_current(
connection,
snapshot,
previous_window,
previous_focus,
"new-window",
)
.await?;
}
if print {
let pane = pane_showing(&snapshot, buffer.buffer.id);
let format = format.unwrap_or_else(|| DEFAULT_PANE_FORMAT.to_owned());
return connection.expand_for_pane(&snapshot, pane, &format).await;
}
Ok(format!("{number}\t{title}"))
}
Command::ListWindows {
target,
all,
format,
} => {
let snapshots = if all {
let mut snapshots = Vec::new();
for session in connection.list_sessions().await? {
snapshots.push(connection.session_snapshot(session.id).await?);
}
snapshots
} else {
vec![
connection
.resolve_session_snapshot(target.as_deref())
.await?,
]
};
let mut lines = Vec::new();
for snapshot in snapshots {
match &format {
None => {
let base_index = connection.base_index_for(&snapshot);
lines.push(format_windows(&snapshot, base_index)?);
}
Some(format) => {
for pane in window_panes(&snapshot)? {
lines.push(connection.expand_for_pane(&snapshot, pane, format).await?);
}
}
}
}
Ok(lines.join("\n"))
}
Command::SelectWindow { target } => {
let window = connection.resolve_window(Some(&target)).await?;
connection
.request(ClientMessage::Session(SessionRequest::SelectRootTab {
request_id: new_request_id(),
session_id: window.snapshot.session.id,
index: window.index,
}))
.await?;
Ok(String::new())
}
Command::RenameWindow { target, title } => {
let window = connection.resolve_window(target.as_deref()).await?;
connection
.request(ClientMessage::Session(SessionRequest::RenameRootTab {
request_id: new_request_id(),
session_id: window.snapshot.session.id,
index: window.index,
title,
}))
.await?;
Ok(String::new())
}
Command::SynchronizePanes { target, state } => {
let window = connection.resolve_window(target.as_deref()).await?;
let response = connection
.request(ClientMessage::Session(
SessionRequest::SetRootTabSynchronizePanes {
request_id: new_request_id(),
session_id: window.snapshot.session.id,
index: window.index,
mode: match state {
None => SynchronizePanesMode::Toggle,
Some(SwitchArg::On) => SynchronizePanesMode::On,
Some(SwitchArg::Off) => SynchronizePanesMode::Off,
},
},
))
.await?;
let snapshot = expect_session_snapshot(response, "synchronize-panes")?;
let (_, tabs) = root_tabs(&snapshot)?;
let enabled = protocol_tab(tabs, window.index).is_some_and(|tab| tab.synchronize_panes);
Ok(if enabled { "on" } else { "off" }.to_owned())
}
Command::KillWindow { target, detach } => {
let window = connection.resolve_window(target.as_deref()).await?;
let shown = buffer_ids_in_subtree(&window.snapshot.nodes, window.child_id);
connection
.request(ClientMessage::Session(SessionRequest::CloseRootTab {
request_id: new_request_id(),
session_id: window.snapshot.session.id,
index: window.index,
}))
.await?;
if !detach {
connection.kill_detached_buffers(shown).await?;
}
Ok(String::new())
}
Command::SplitWindow {
target,
side_by_side: _,
stacked,
horizontal,
vertical: _,
cwd,
detached,
print,
format,
help: _,
command,
} => {
let pane = connection.resolve_pane(target.as_deref()).await?;
let previous_window = current_root_window(&pane.snapshot)?;
let previous_focus = pane.snapshot.session.focused_leaf_id;
let command = buffer_command(command);
let direction = if horizontal || stacked {
SplitDirection::Horizontal
} else {
SplitDirection::Vertical
};
let cwd = connection
.spawn_cwd_for_pane(cwd.as_deref(), &pane.snapshot, pane.leaf_id)
.await?;
let size = spawn_size(
&pane.snapshot,
PanePlacement::Split {
leaf_id: pane.leaf_id,
direction,
},
);
let buffer = connection
.create_buffer(
Some(default_title(&command, "pane")),
command,
cwd,
size,
pane.snapshot.session.id,
)
.await?;
let response = connection
.request(ClientMessage::Node(NodeRequest::Split {
request_id: new_request_id(),
leaf_node_id: pane.leaf_id,
direction,
new_buffer_id: buffer.buffer.id,
}))
.await;
let response = rollback_created_buffer_on_error(
connection,
buffer.buffer.id,
"split-window",
response,
)
.await?;
let mut snapshot = rollback_created_buffer_on_error(
connection,
buffer.buffer.id,
"split-window",
expect_session_snapshot(response, "split-window"),
)
.await?;
let new_pane = pane_showing(&snapshot, buffer.buffer.id).ok_or_else(|| {
MuxError::protocol("split-window response does not show the new pane")
})?;
if detached {
let response = connection
.request(ClientMessage::Node(NodeRequest::SelectPane {
request_id: new_request_id(),
session_id: snapshot.session.id,
node_id: pane.leaf_id,
}))
.await?;
snapshot = expect_session_snapshot(response, "split-window")?;
snapshot = restore_current(
connection,
snapshot,
previous_window,
previous_focus,
"split-window",
)
.await?;
}
if print {
let format = format.unwrap_or_else(|| DEFAULT_PANE_FORMAT.to_owned());
return connection
.expand_for_pane(&snapshot, Some(new_pane), &format)
.await;
}
Ok(new_pane.to_string())
}
Command::ListPanes {
target,
session,
all,
format,
} => {
let mut panes = Vec::new();
if all || session {
let snapshots = if all {
let mut snapshots = Vec::new();
for session in connection.list_sessions().await? {
snapshots.push(connection.session_snapshot(session.id).await?);
}
snapshots
} else {
vec![
connection
.resolve_session_snapshot(target.as_deref())
.await?,
]
};
for snapshot in snapshots {
let mut leaf_ids = Vec::new();
for window in window_children(&snapshot)? {
leaf_ids.extend(visible_leaf_ids(&snapshot, window)?);
}
panes.push((snapshot, leaf_ids));
}
} else {
let window = connection.resolve_window(target.as_deref()).await?;
let leaf_ids = visible_leaf_ids(&window.snapshot, window.child_id)?;
panes.push((window.snapshot, leaf_ids));
}
let mut lines = Vec::new();
for (snapshot, leaf_ids) in panes {
match &format {
None => lines.push(format_panes(&snapshot, &leaf_ids)?),
Some(format) => {
for leaf_id in leaf_ids {
lines.push(
connection
.expand_for_pane(&snapshot, Some(leaf_id), format)
.await?,
);
}
}
}
}
Ok(lines.join("\n"))
}
Command::SelectPane {
target, last: true, ..
} => {
let session = connection.resolve_session_record(target.as_deref()).await?;
connection
.request(ClientMessage::Node(NodeRequest::FocusLast {
request_id: new_request_id(),
session_id: session.id,
}))
.await?;
Ok(String::new())
}
Command::SelectPane {
target,
left,
right,
up,
down,
..
} => {
let direction = [
(left, NavigationDirection::Left),
(right, NavigationDirection::Right),
(up, NavigationDirection::Up),
(down, NavigationDirection::Down),
]
.into_iter()
.find_map(|(set, direction)| set.then_some(direction));
let pane = connection.resolve_pane(target.as_deref()).await?;
let pane_id = match direction {
None => pane.leaf_id,
Some(direction) => {
let (_, presentation) = formats::project(&pane.snapshot, pane.leaf_id)?;
match presentation.focus_target_from(pane.leaf_id, direction) {
Some(pane_id) => pane_id,
None => return Ok(String::new()),
}
}
};
connection
.request(ClientMessage::Node(NodeRequest::SelectPane {
request_id: new_request_id(),
session_id: pane.snapshot.session.id,
node_id: pane_id,
}))
.await?;
Ok(String::new())
}
Command::SetOption {
pane,
window,
server,
global,
target,
unset,
only_if_unset,
quiet,
name,
value,
} => {
connection
.set_option(options::SetOption {
scope: options::Scope {
pane,
window,
server,
global,
},
target,
name,
value,
unset,
only_if_unset,
quiet,
})
.await
}
Command::ShowOptions {
pane,
window,
server,
global,
target,
value_only,
quiet,
name,
} => {
connection
.show_options(options::ShowOptions {
scope: options::Scope {
pane,
window,
server,
global,
},
target,
name,
value_only,
quiet,
})
.await
}
Command::ResizePane {
target,
sizes,
left,
right,
up,
down,
zoom,
adjustment,
} => {
let direction = [
(left, NavigationDirection::Left),
(right, NavigationDirection::Right),
(up, NavigationDirection::Up),
(down, NavigationDirection::Down),
]
.into_iter()
.find_map(|(set, direction)| set.then_some(direction));
if direction.is_none() && !zoom && sizes.is_empty() {
return Err(MuxError::invalid_input(
"resize-pane requires -L/-R/-U/-D, -Z, or --sizes",
));
}
let pane = connection.resolve_pane(target.as_deref()).await?;
if zoom {
let response = connection
.request(ClientMessage::Node(NodeRequest::ToggleZoom {
request_id: new_request_id(),
node_id: pane.leaf_id,
}))
.await?;
expect_ok(response, "resize-pane -Z")?;
return Ok(String::new());
}
if let Some(direction) = direction {
if let Some(resize) =
plan_directional_resize(&pane, direction, adjustment.unwrap_or(1))?
{
connection
.request(ClientMessage::Node(NodeRequest::Resize {
request_id: new_request_id(),
node_id: resize.split_node_id,
sizes: resize.sizes,
}))
.await?;
}
return Ok(String::new());
}
let leaf = node_record(&pane.snapshot, pane.leaf_id)?;
let parent_id = leaf
.parent_id
.ok_or_else(|| MuxError::invalid_input("pane is not inside a resizable split"))?;
let parent = node_record(&pane.snapshot, parent_id)?;
if parent.kind != embers_protocol::NodeRecordKind::Split {
return Err(MuxError::invalid_input(
"pane parent is not a split and cannot be resized",
));
}
connection
.request(ClientMessage::Node(NodeRequest::Resize {
request_id: new_request_id(),
node_id: parent_id,
sizes,
}))
.await?;
Ok(String::new())
}
Command::SendKeys {
target,
enter,
keys,
} => {
let buffer_id = connection.resolve_buffer(target.as_deref()).await?;
if keys.is_empty() && !enter {
return Err(MuxError::invalid_input(
"send-keys requires at least one key or --enter",
));
}
let mut bytes = keys.join(" ").into_bytes();
if enter {
bytes.push(b'\r');
}
connection
.request(ClientMessage::Input(embers_protocol::InputRequest::Send {
request_id: new_request_id(),
buffer_id,
bytes,
}))
.await?;
Ok(String::new())
}
Command::CapturePane {
target,
print,
start,
end,
join,
escapes,
} => {
let buffer_id = connection.resolve_buffer(target.as_deref()).await?;
if print || start.is_some() || end.is_some() || join || escapes {
return connection
.capture_lines(
buffer_id,
capture::CaptureOptions {
start,
end,
join,
escapes,
},
)
.await;
}
let response = connection
.request(ClientMessage::Buffer(BufferRequest::Capture {
request_id: new_request_id(),
buffer_id,
}))
.await?;
let snapshot = expect_capture(response, "capture-pane")?;
Ok(snapshot.lines.join("\n"))
}
Command::KillPane { target, detach } => {
let pane = connection.resolve_pane(target.as_deref()).await?;
connection
.request(ClientMessage::Node(NodeRequest::Close {
request_id: new_request_id(),
node_id: pane.leaf_id,
}))
.await?;
if !detach {
connection
.kill_detached_buffers(vec![pane.buffer_id])
.await?;
}
Ok(String::new())
}
Command::DisplayPopup {
target,
title,
x,
y,
width,
height,
close_on_exit,
cwd,
print,
format,
no_wait,
no_border,
help: _,
command,
} => {
let (snapshot, pane_id) = match connection.resolve_pane(target.as_deref()).await {
Ok(pane) => (pane.snapshot, Some(pane.leaf_id)),
Err(error) => match target.as_deref() {
Some(target) => (
connection.resolve_session_snapshot(Some(target)).await?,
None,
),
None => return Err(error),
},
};
let session_id = snapshot.session.id;
let command = buffer_command(command);
let popup_title = match title {
Some(title) => {
connection
.expand_for_pane(&snapshot, pane_id, &title)
.await?
}
None => default_title(&command, "popup"),
};
let cwd = match pane_id {
Some(pane_id) => {
connection
.spawn_cwd_for_pane(cwd.as_deref(), &snapshot, pane_id)
.await?
}
None => {
connection
.spawn_cwd(cwd.as_deref(), Some(&snapshot))
.await?
}
};
let geometry =
popup::popup_geometry(formats::session_viewport(&snapshot), width, height, x, y);
let wait = connection.wait_for_popups && !no_wait;
let close = if close_on_exit >= 2 {
popup::CloseOnExit::OnSuccess
} else {
popup::CloseOnExit::Always
};
let border = !no_border;
let size = spawn_size(&snapshot, PanePlacement::Floating { geometry, border });
let buffer = connection
.create_buffer(Some(popup_title.clone()), command, cwd, size, session_id)
.await?;
let remain_on_exit = match (wait, close) {
(true, _) => Some("on"),
(false, popup::CloseOnExit::OnSuccess) => Some("failed"),
(false, popup::CloseOnExit::Always) => None,
};
if let Some(value) = remain_on_exit {
let response = connection
.request(ClientMessage::Buffer(BufferRequest::SetUserOption {
request_id: new_request_id(),
buffer_id: buffer.buffer.id,
key: popup::REMAIN_ON_EXIT.to_owned(),
value: Some(value.to_owned()),
}))
.await;
rollback_created_buffer_on_error(
connection,
buffer.buffer.id,
"display-popup",
response,
)
.await?;
}
let response = connection
.request(ClientMessage::Floating(FloatingRequest::Create {
request_id: new_request_id(),
session_id,
root_node_id: None,
buffer_id: Some(buffer.buffer.id),
geometry,
title: Some(popup_title),
focus: true,
close_on_empty: true,
border,
}))
.await;
let response = rollback_created_buffer_on_error(
connection,
buffer.buffer.id,
"display-popup",
response,
)
.await?;
let popup = rollback_created_buffer_on_error(
connection,
buffer.buffer.id,
"display-popup",
expect_floating(response, "display-popup"),
)
.await?;
let output = if print {
let snapshot = connection.session_snapshot(session_id).await?;
let pane = pane_showing(&snapshot, buffer.buffer.id);
let format = format
.unwrap_or_else(|| "#{popup_id}".to_owned())
.replace("#{popup_id}", &popup.id.to_string());
connection.expand_for_pane(&snapshot, pane, &format).await?
} else if wait {
String::new()
} else {
popup.id.to_string()
};
if wait {
if !output.is_empty() {
println!("{output}");
}
let socket = connection.socket_path.clone();
let status = connection
.wait_for_popup(
&socket,
popup::WaitedPopup {
session_id,
floating_id: popup.id,
buffer_id: buffer.buffer.id,
close,
},
)
.await?;
connection.exit_status = Some(status);
return Ok(String::new());
}
Ok(output)
}
Command::DisplayMessage {
target,
client,
print,
duration,
message,
} => {
let client = client.or(connection.default_client);
let template = if message.is_empty() {
DEFAULT_DISPLAY_MESSAGE.to_owned()
} else {
message.join(" ")
};
let pane = match (&target, client) {
(None, Some(client_id)) => {
let session_id = connection
.client_record(client_id)
.await?
.current_session_id
.ok_or_else(|| {
MuxError::not_found(format!("client {client_id} shows no session"))
})?;
let snapshot = connection.session_snapshot(session_id).await?;
let pane_id = snapshot
.session
.focused_leaf_id
.ok_or_else(|| MuxError::not_found("session has no focused pane"))?;
resolved_pane(snapshot, pane_id)?
}
_ => connection.resolve_pane(target.as_deref()).await?,
};
let session_id = pane.snapshot.session.id;
let text = connection
.expand_for_pane(&pane.snapshot, Some(pane.leaf_id), &template)
.await?;
if print {
return Ok(text);
}
let response = connection
.request(ClientMessage::Client(ClientRequest::DisplayMessage {
request_id: new_request_id(),
client_id: client,
session_id: client.is_none().then_some(session_id),
message: text,
duration_ms: duration.unwrap_or(0),
}))
.await?;
expect_ok(response, "display-message")?;
Ok(String::new())
}
Command::SetEnvironment {
target,
global,
unset,
name,
value,
} => {
if !unset && value.is_none() {
return Err(MuxError::invalid_input(format!(
"set-environment {name} requires a value (or -u to unset it)"
)));
}
let session_id = if global {
None
} else {
Some(
connection
.resolve_session_record(target.as_deref())
.await?
.id,
)
};
let response = connection
.request(ClientMessage::Session(SessionRequest::SetEnvironment {
request_id: new_request_id(),
session_id,
name,
value,
}))
.await?;
expect_ok(response, "set-environment")?;
Ok(String::new())
}
Command::ShowEnvironment {
target,
global,
name,
} => {
let session_id = if global {
None
} else {
Some(
connection
.resolve_session_record(target.as_deref())
.await?
.id,
)
};
let env = match connection
.request(ClientMessage::Session(SessionRequest::ShowEnvironment {
request_id: new_request_id(),
session_id,
}))
.await?
{
ServerResponse::Environment(response) => response.env,
other => {
return Err(MuxError::protocol(format!(
"unexpected response to show-environment: {other:?}"
)));
}
};
match name {
Some(name) => env
.get(&name)
.map(|value| format!("{name}={value}"))
.ok_or_else(|| MuxError::not_found(format!("unknown variable: {name}"))),
None => Ok(env
.iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>()
.join("\n")),
}
}
Command::KillPopup { target, detach } => {
let popup = connection.resolve_popup(target.as_deref()).await?;
let snapshot = connection.session_snapshot(popup.session_id).await?;
let shown = buffer_ids_in_subtree(&snapshot.nodes, popup.root_node_id);
connection
.request(ClientMessage::Floating(FloatingRequest::Close {
request_id: new_request_id(),
floating_id: popup.id,
}))
.await?;
if !detach {
connection.kill_detached_buffers(shown).await?;
}
Ok(String::new())
}
}
}
pub async fn run(cli: Cli) -> Result<()> {
let log_filter = cli.log_filter();
let base_index = cli.base_index();
let Cli {
socket,
config,
command,
..
} = cli;
match command {
Some(Command::RuntimeKeeper {
keeper_socket,
cols,
rows,
pixel_width,
pixel_height,
cwd,
env,
command,
}) => embers_server::run_runtime_keeper(embers_server::RuntimeKeeperCli {
socket_path: keeper_socket,
command,
cwd,
env: env.into_iter().collect(),
size: embers_core::PtySize {
cols,
rows,
pixel_width,
pixel_height,
},
}),
command => {
let socket = resolve_socket_path(socket.as_deref());
validate_runtime_socket_parent(&socket)?;
match command {
None => {
ensure_server_process(&socket, &log_filter).await?;
interactive::run(socket, None, config).await
}
Some(Command::Attach { target }) => {
if !server_is_available(&socket).await {
return Err(MuxError::not_found(format!(
"no embers server is listening on {}",
socket.display()
)));
}
let target = match target {
Some(target) => Some(
CliConnection::connect(&socket)
.await?
.resolve_session_record(Some(&target))
.await?
.name,
),
None => None,
};
interactive::run(socket, target, config).await
}
Some(Command::Automation {
target,
all_sessions,
}) => {
ensure_server_process(&socket, &log_filter).await?;
automation::run(socket, target, all_sessions).await
}
Some(Command::Serve) => run_server(socket, &log_filter).await,
Some(Command::KillServer) => kill_server(&socket).await,
Some(command) => {
ensure_server_process(&socket, &log_filter).await?;
let outcome = execute(&socket, command, base_index).await?;
if !outcome.output.is_empty() {
println!("{}", outcome.output);
}
if let Some(session) = outcome.attach_session {
return interactive::run(socket, Some(session), config).await;
}
if let Some(status) = outcome.exit_status.filter(|status| *status != 0) {
std::io::stdout().flush()?;
std::process::exit(status);
}
Ok(())
}
}
}
}
}
fn resolve_socket_path(explicit: Option<&Path>) -> PathBuf {
explicit
.map(Path::to_path_buf)
.or_else(|| std::env::var_os(SOCKET_ENV_VAR).map(PathBuf::from))
.unwrap_or_else(default_socket_path)
}
fn default_socket_path() -> PathBuf {
default_runtime_dir().join("embers.sock")
}
fn default_runtime_dir() -> PathBuf {
if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR").filter(|value| !value.is_empty())
{
return PathBuf::from(runtime_dir).join("embers");
}
#[cfg(unix)]
{
let run_user_dir = PathBuf::from(format!("/run/user/{}", effective_uid()));
if run_user_dir.is_dir() {
return run_user_dir.join("embers");
}
}
PathBuf::from("/tmp").join(format!("embers-{}", effective_uid()))
}
fn parse_env_arg(value: &str) -> std::result::Result<(String, OsString), String> {
let Some((key, env_value)) = value.split_once('=') else {
return Err("expected KEY=VALUE".to_owned());
};
if key.is_empty() {
return Err("environment key must not be empty".to_owned());
}
Ok((key.to_owned(), decode_runtime_keeper_env_value(env_value)?))
}
fn parse_string_env_arg(value: &str) -> std::result::Result<(String, String), String> {
let Some((key, env_value)) = value.split_once('=') else {
return Err("expected KEY=VALUE".to_owned());
};
if key.is_empty() {
return Err("environment key must not be empty".to_owned());
}
Ok((key.to_owned(), env_value.to_owned()))
}
fn decode_runtime_keeper_env_value(value: &str) -> std::result::Result<OsString, String> {
let Some(encoded) = value.strip_prefix("base64:") else {
return Ok(OsString::from(value));
};
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|error| format!("invalid base64 environment value: {error}"))?;
#[cfg(unix)]
{
Ok(OsString::from_vec(decoded))
}
#[cfg(windows)]
{
if decoded.len() % 2 != 0 {
return Err("invalid UTF-16LE environment value: odd-length byte sequence".to_owned());
}
let wide = decoded
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect::<Vec<_>>();
Ok(OsString::from_wide(&wide))
}
#[cfg(all(not(unix), not(windows)))]
{
String::from_utf8(decoded)
.map(OsString::from)
.map_err(|error| format!("invalid UTF-8 environment value: {error}"))
}
}
#[cfg(unix)]
fn effective_uid() -> u32 {
unsafe { libc::geteuid() }
}
#[cfg(not(unix))]
fn effective_uid() -> u32 {
0
}
fn pid_path(socket_path: &Path) -> PathBuf {
socket_path.with_extension("pid")
}
async fn server_is_available(socket_path: &Path) -> bool {
if validate_runtime_socket_parent(socket_path).is_err() {
return false;
}
CliConnection::connect(socket_path).await.is_ok()
}
async fn ensure_server_process(socket_path: &Path, log_filter: &str) -> Result<()> {
if server_is_available(socket_path).await {
return Ok(());
}
ensure_socket_parent(socket_path)?;
let current_exe = std::env::current_exe()?;
let mut child = ProcessCommand::new(current_exe)
.arg("__serve")
.arg("--socket")
.arg(socket_path)
.env(EMBERS_LOG_ENV_VAR, log_filter)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
let mut exited_status = None;
loop {
if server_is_available(socket_path).await {
return Ok(());
}
if let Some(status) = child.try_wait()? {
exited_status.get_or_insert(status);
if server_is_available(socket_path).await {
return Ok(());
}
}
if tokio::time::Instant::now() >= deadline {
if let Some(status) = exited_status {
return Err(MuxError::transport(format!(
"embers server exited before becoming ready with status {status}"
)));
}
let _ = child.kill();
let _ = child.wait();
return Err(MuxError::timeout(format!(
"timed out waiting for embers server at {}",
socket_path.display()
)));
}
sleep(Duration::from_millis(25)).await;
}
}
async fn run_server(socket_path: PathBuf, log_filter: &str) -> Result<()> {
ensure_socket_parent(&socket_path)?;
if let Some(dir) = socket_path.parent() {
embers_core::init_server_tracing(log_filter, dir)?;
install_server_panic_hook();
}
let secure_parent = socket_path
.parent()
.is_some_and(|parent| parent == default_runtime_dir().as_path());
let _pid = ServerPidFile::create(&pid_path(&socket_path), secure_parent)?;
let handle = Server::new(ServerConfig::new(socket_path)).start().await?;
tokio::select! {
result = wait_for_shutdown_signal() => result?,
() = handle.stopped() => {}
}
handle.shutdown().await
}
const KILL_SERVER_TIMEOUT: Duration = Duration::from_secs(10);
async fn kill_server(socket_path: &Path) -> Result<()> {
if !server_is_available(socket_path).await {
return Err(MuxError::not_found(format!(
"no embers server is listening on {}",
socket_path.display()
)));
}
execute(socket_path, Command::KillServer, None).await?;
let deadline = tokio::time::Instant::now() + KILL_SERVER_TIMEOUT;
while server_is_available(socket_path).await {
if tokio::time::Instant::now() >= deadline {
return Err(MuxError::timeout(format!(
"embers server on {} did not stop",
socket_path.display()
)));
}
sleep(Duration::from_millis(25)).await;
}
Ok(())
}
fn install_server_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
tracing::error!(target: "panic", "server panicked: {info}");
previous(info);
}));
}
fn ensure_socket_parent(socket_path: &Path) -> Result<()> {
let Some(parent) = socket_path.parent() else {
return Ok(());
};
if parent == default_runtime_dir().as_path() {
ensure_private_dir(parent)
} else {
fs::create_dir_all(parent)?;
Ok(())
}
}
fn ensure_private_dir(path: &Path) -> Result<()> {
fs::create_dir_all(path)?;
#[cfg(unix)]
{
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
validate_private_dir(path)?;
}
Ok(())
}
fn validate_runtime_socket_parent(socket_path: &Path) -> Result<()> {
let Some(parent) = socket_path.parent() else {
return Ok(());
};
if parent != default_runtime_dir().as_path() || !parent.exists() {
return Ok(());
}
validate_private_dir(parent)
}
#[cfg(unix)]
fn validate_private_dir(path: &Path) -> Result<()> {
let metadata = fs::metadata(path)?;
if !metadata.is_dir() {
return Err(MuxError::invalid_input(format!(
"runtime directory {} is not a directory",
path.display()
)));
}
if metadata.uid() != effective_uid() {
return Err(MuxError::invalid_input(format!(
"runtime directory {} is not owned by uid {}",
path.display(),
effective_uid()
)));
}
if metadata.permissions().mode() & 0o777 != 0o700 {
return Err(MuxError::invalid_input(format!(
"runtime directory {} must have mode 0700",
path.display()
)));
}
Ok(())
}
#[cfg(not(unix))]
fn validate_private_dir(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(unix)]
async fn wait_for_shutdown_signal() -> Result<()> {
let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
let mut hangup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
tokio::select! {
_ = interrupt.recv() => Ok(()),
_ = terminate.recv() => Ok(()),
_ = hangup.recv() => Ok(()),
}
}
#[cfg(not(unix))]
async fn wait_for_shutdown_signal() -> Result<()> {
tokio::signal::ctrl_c().await?;
Ok(())
}
struct ServerPidFile {
path: PathBuf,
}
impl ServerPidFile {
fn create(path: &Path, secure_parent: bool) -> Result<Self> {
if let Some(parent) = path.parent() {
if secure_parent {
ensure_private_dir(parent)?;
} else {
fs::create_dir_all(parent)?;
}
}
match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err(MuxError::conflict(format!(
"refusing to overwrite symlink pid file {}",
path.display()
)));
}
let pid_text = fs::read_to_string(path).map_err(|error| {
MuxError::conflict(format!(
"refusing to overwrite unreadable pid file {}: {error}",
path.display()
))
})?;
let pid = pid_text.trim().parse::<u32>().map_err(|error| {
MuxError::conflict(format!(
"refusing to overwrite invalid pid file {}: {error}",
path.display()
))
})?;
if process_is_alive(pid)? {
return Err(MuxError::conflict(format!(
"refusing to overwrite active pid file {} for running process {pid}",
path.display()
)));
}
fs::remove_file(path)?;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
#[cfg(unix)]
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
#[cfg(not(unix))]
let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
file.write_all(std::process::id().to_string().as_bytes())?;
Ok(Self {
path: path.to_path_buf(),
})
}
}
#[cfg(unix)]
fn process_is_alive(pid: u32) -> Result<bool> {
let result = unsafe { libc::kill(pid as i32, 0) };
if result == 0 {
return Ok(true);
}
match std::io::Error::last_os_error().raw_os_error() {
Some(libc::ESRCH) => Ok(false),
Some(libc::EPERM) => Ok(true),
Some(code) => Err(MuxError::transport(format!(
"failed to validate process {pid}: os error {code}"
))),
None => Err(MuxError::transport(format!(
"failed to validate process {pid}: unknown os error"
))),
}
}
#[cfg(not(unix))]
fn process_is_alive(_pid: u32) -> Result<bool> {
Err(MuxError::conflict(
"pid file validation is unsupported on this platform".to_owned(),
))
}
impl Drop for ServerPidFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub struct PromptCommandRunner {
socket_path: PathBuf,
}
impl PromptCommandRunner {
pub fn new(socket_path: PathBuf) -> Self {
Self { socket_path }
}
}
#[async_trait::async_trait]
impl CommandRunner for PromptCommandRunner {
async fn run(&self, argv: Vec<String>, context: CommandContext) -> Result<String> {
let cli = Cli::try_parse_from(std::iter::once("embers".to_owned()).chain(argv))
.map_err(|error| MuxError::invalid_input(clap_error_summary(&error)))?;
if let Some(flag) = prompt_ignored_global_flag(&cli) {
return Err(MuxError::invalid_input(format!(
"{flag} cannot be used from the command prompt"
)));
}
let base_index = cli.base_index.or(context.base_index);
let command = cli
.command
.ok_or_else(|| MuxError::invalid_input("no command given"))?;
if let Some(name) = interactive_command_name(&command) {
return Err(MuxError::invalid_input(format!(
"{name} cannot be run from the command prompt"
)));
}
let mut connection = CliConnection::connect(&self.socket_path).await?;
connection.base_index = base_index;
connection.default_session = context.session_id;
connection.default_client = context.client_id.and_then(NonZeroU64::new);
execute_command(&mut connection, command).await
}
}
fn prompt_ignored_global_flag(cli: &Cli) -> Option<&'static str> {
if cli.socket.is_some() {
Some("--socket")
} else if cli.config.is_some() {
Some("--config")
} else if cli.log.is_some() {
Some("--log")
} else if cli.verbose != 0 || cli.verbose_short != 0 {
Some("--verbose")
} else {
None
}
}
fn clap_error_summary(error: &clap::Error) -> String {
let rendered = error.to_string();
let line = rendered.lines().next().unwrap_or_default();
line.strip_prefix("error: ").unwrap_or(line).to_owned()
}
fn interactive_command_name(command: &Command) -> Option<&'static str> {
match command {
Command::Attach { .. } => Some("attach"),
Command::Serve => Some("__serve"),
Command::RuntimeKeeper { .. } => Some("__runtime-keeper"),
Command::Automation { .. } => Some("automation"),
_ => None,
}
}
#[derive(Debug)]
struct CliConnection {
client: ProtocolClient,
base_index: Option<u32>,
default_session: Option<SessionId>,
default_client: Option<NonZeroU64>,
pane_buffer: Option<BufferId>,
caller_client: Option<NonZeroU64>,
socket_path: PathBuf,
working_dir: Option<String>,
wait_for_popups: bool,
can_attach: bool,
attach_session: Option<String>,
exit_status: Option<i32>,
}
fn pane_buffer(
buffer: Option<&str>,
pane_socket: Option<&OsStr>,
socket: &Path,
) -> Option<BufferId> {
let buffer_id = buffer?.trim().parse::<u64>().ok()?;
(Path::new(pane_socket?) == socket).then_some(BufferId(buffer_id))
}
impl CliConnection {
async fn connect(path: impl AsRef<Path>) -> Result<Self> {
let client = ProtocolClient::connect(path.as_ref())
.await
.map_err(|error| MuxError::transport(error.to_string()))?;
Ok(Self {
client,
base_index: None,
default_session: None,
default_client: None,
pane_buffer: None,
caller_client: None,
socket_path: path.as_ref().to_path_buf(),
working_dir: None,
wait_for_popups: false,
can_attach: false,
attach_session: None,
exit_status: None,
})
}
fn base_index_for(&self, snapshot: &SessionSnapshot) -> Option<u32> {
self.base_index.or(snapshot.session.base_index)
}
async fn pane_location(&mut self) -> Result<Option<BufferLocation>> {
let Some(buffer_id) = self.pane_buffer else {
return Ok(None);
};
let response = self
.request(ClientMessage::Buffer(BufferRequest::GetLocation {
request_id: new_request_id(),
buffer_id,
}))
.await?;
expect_buffer_location(response, "buffer location").map(Some)
}
async fn resolve_buffer(&mut self, target: Option<&str>) -> Result<BufferId> {
match (target, self.pane_buffer) {
(None, Some(buffer_id)) => Ok(buffer_id),
_ => Ok(self.resolve_pane(target).await?.buffer_id),
}
}
async fn request(&mut self, message: ClientMessage) -> Result<ServerResponse> {
match self
.client
.request(&message)
.await
.map_err(|error| MuxError::transport(error.to_string()))?
{
ServerResponse::Error(response) => Err(response.error.into()),
response => Ok(response),
}
}
async fn list_sessions(&mut self) -> Result<Vec<SessionRecord>> {
match self
.request(ClientMessage::Session(SessionRequest::List {
request_id: new_request_id(),
}))
.await?
{
ServerResponse::Sessions(response) => Ok(response.sessions),
other => Err(MuxError::protocol(format!(
"unexpected response to list-sessions: {other:?}"
))),
}
}
async fn list_clients(&mut self) -> Result<Vec<ClientRecord>> {
match self
.request(ClientMessage::Client(ClientRequest::List {
request_id: new_request_id(),
}))
.await?
{
ServerResponse::Clients(response) => Ok(response.clients),
other => Err(MuxError::protocol(format!(
"unexpected response to list-clients: {other:?}"
))),
}
}
async fn client_record(&mut self, client_id: NonZeroU64) -> Result<ClientRecord> {
match self
.request(ClientMessage::Client(ClientRequest::Get {
request_id: new_request_id(),
client_id: Some(client_id),
}))
.await?
{
ServerResponse::Client(response) => Ok(response.client),
other => Err(MuxError::protocol(format!(
"unexpected response to client get: {other:?}"
))),
}
}
async fn kill_detached_buffers(&mut self, buffer_ids: Vec<BufferId>) -> Result<()> {
for buffer_id in buffer_ids {
let buffer = match self
.request(ClientMessage::Buffer(BufferRequest::Get {
request_id: new_request_id(),
buffer_id,
}))
.await
{
Ok(ServerResponse::Buffer(response)) => response.buffer,
Ok(_) => continue,
Err(error) if error.is_not_found() => continue,
Err(error) => return Err(error),
};
if buffer.attachment_node_id.is_some()
|| !matches!(
buffer.state,
BufferRecordState::Running | BufferRecordState::Interrupted
)
{
continue;
}
match self
.request(ClientMessage::Buffer(BufferRequest::Kill {
request_id: new_request_id(),
buffer_id,
force: false,
}))
.await
{
Ok(_) => {}
Err(error) if error.is_not_found() || error.is_conflict() => {}
Err(error) => return Err(error),
}
}
Ok(())
}
async fn session_snapshot(&mut self, session_id: SessionId) -> Result<SessionSnapshot> {
match self
.request(ClientMessage::Session(SessionRequest::Get {
request_id: new_request_id(),
session_id,
}))
.await?
{
ServerResponse::SessionSnapshot(response) => Ok(response.snapshot),
other => Err(MuxError::protocol(format!(
"unexpected response to session get: {other:?}"
))),
}
}
async fn create_buffer(
&mut self,
title: Option<String>,
command: Vec<String>,
cwd: Option<String>,
size: Option<PtySize>,
session_id: SessionId,
) -> Result<BufferResponse> {
match self
.request(ClientMessage::Buffer(BufferRequest::Create {
request_id: new_request_id(),
title,
command,
cwd,
env: Default::default(),
size,
session_id: Some(session_id),
}))
.await?
{
ServerResponse::Buffer(response) => Ok(response),
other => Err(MuxError::protocol(format!(
"unexpected response to buffer create: {other:?}"
))),
}
}
async fn rollback_created_buffer(&mut self, buffer_id: BufferId, operation: &str) {
if let Err(error) = self
.request(ClientMessage::Buffer(BufferRequest::Detach {
request_id: new_request_id(),
buffer_id,
}))
.await
{
warn!(
%buffer_id,
%error,
operation,
"failed to detach created buffer during rollback"
);
}
if let Err(error) = self
.request(ClientMessage::Buffer(BufferRequest::Kill {
request_id: new_request_id(),
buffer_id,
force: true,
}))
.await
{
warn!(
%buffer_id,
%error,
operation,
"failed to kill created buffer during rollback"
);
}
}
async fn resolve_session_record(&mut self, target: Option<&str>) -> Result<SessionRecord> {
self.target_session(target).await
}
async fn resolve_session_snapshot(&mut self, target: Option<&str>) -> Result<SessionSnapshot> {
let session = self.resolve_session_record(target).await?;
self.session_snapshot(session.id).await
}
async fn resolve_window(&mut self, target: Option<&str>) -> Result<ResolvedWindow> {
self.target_window(target).await
}
async fn resolve_pane(&mut self, target: Option<&str>) -> Result<ResolvedPane> {
self.target_pane(target).await
}
async fn resolve_popup(&mut self, target: Option<&str>) -> Result<FloatingRecord> {
match target {
Some(target) => {
let (session_target, selector) = split_scoped_required(target, "popup target")?;
let popup_id = parse_floating_id(&selector)?;
if let Some(session_target) = session_target {
let snapshot = self.resolve_session_snapshot(Some(&session_target)).await?;
floating_record(&snapshot, popup_id).cloned()
} else {
let sessions = self.list_sessions().await?;
for session in sessions {
let snapshot = self.session_snapshot(session.id).await?;
if let Ok(popup) = floating_record(&snapshot, popup_id) {
return Ok(popup.clone());
}
}
Err(MuxError::not_found(format!(
"popup {popup_id} was not found"
)))
}
}
None => {
if let Some(BufferLocationAttachment::Floating {
session_id,
floating_id,
..
}) = self
.pane_location()
.await?
.map(|location| location.attachment)
{
let snapshot = self.session_snapshot(session_id).await?;
return floating_record(&snapshot, floating_id).cloned();
}
let snapshot = self.resolve_session_snapshot(None).await?;
let popup_id = snapshot
.session
.focused_floating_id
.ok_or_else(|| MuxError::not_found("session has no focused popup"))?;
floating_record(&snapshot, popup_id).cloned()
}
}
}
async fn find_session_containing_pane(&mut self, pane_id: NodeId) -> Result<SessionSnapshot> {
let sessions = self.list_sessions().await?;
for session in sessions {
let snapshot = self.session_snapshot(session.id).await?;
if node_record(&snapshot, pane_id).is_ok() {
return Ok(snapshot);
}
}
Err(MuxError::not_found(format!("pane {pane_id} was not found")))
}
}
async fn rollback_created_buffer_on_error<T>(
connection: &mut CliConnection,
buffer_id: BufferId,
operation: &str,
result: Result<T>,
) -> Result<T> {
match result {
Ok(value) => Ok(value),
Err(error) => {
connection
.rollback_created_buffer(buffer_id, operation)
.await;
Err(error)
}
}
}
#[derive(Debug)]
struct ResolvedWindow {
snapshot: SessionSnapshot,
index: u32,
child_id: NodeId,
}
#[derive(Debug)]
struct ResolvedPane {
snapshot: SessionSnapshot,
leaf_id: NodeId,
buffer_id: BufferId,
}
fn window_containing(snapshot: &SessionSnapshot, node_id: NodeId) -> Result<(u32, NodeId)> {
let (tabs_node, tabs) = root_tabs(snapshot)?;
let mut child_id = node_id;
loop {
let parent_id = node_record(snapshot, child_id)?.parent_id.ok_or_else(|| {
MuxError::not_found(format!("node {node_id} is not in a window of its session"))
})?;
if parent_id == tabs_node.id {
break;
}
child_id = parent_id;
}
let index = tabs
.tabs
.iter()
.position(|tab| tab.child_id == child_id)
.ok_or_else(|| MuxError::protocol(format!("window of node {node_id} is not a root tab")))?;
Ok((
u32::try_from(index).expect("tab index fits into protocol width"),
child_id,
))
}
fn resolved_pane(snapshot: SessionSnapshot, pane_id: NodeId) -> Result<ResolvedPane> {
let leaf = node_record(&snapshot, pane_id)?;
let buffer_id = leaf
.buffer_view
.as_ref()
.map(|view| view.buffer_id)
.ok_or_else(|| MuxError::invalid_input(format!("node {pane_id} is not a pane leaf")))?;
Ok(ResolvedPane {
snapshot,
leaf_id: pane_id,
buffer_id,
})
}
fn expect_session_snapshot(response: ServerResponse, operation: &str) -> Result<SessionSnapshot> {
match response {
ServerResponse::SessionSnapshot(response) => Ok(response.snapshot),
other => Err(MuxError::protocol(format!(
"unexpected response to {operation}: {other:?}"
))),
}
}
fn expect_floating(response: ServerResponse, operation: &str) -> Result<FloatingRecord> {
match response {
ServerResponse::Floating(FloatingResponse { floating, .. }) => Ok(floating),
other => Err(MuxError::protocol(format!(
"unexpected response to {operation}: {other:?}"
))),
}
}
fn expect_capture(response: ServerResponse, operation: &str) -> Result<SnapshotResponse> {
match response {
ServerResponse::Snapshot(snapshot) => Ok(snapshot),
other => Err(MuxError::protocol(format!(
"unexpected response to {operation}: {other:?}"
))),
}
}
fn expect_buffer_location(response: ServerResponse, operation: &str) -> Result<BufferLocation> {
match response {
ServerResponse::BufferLocation(BufferLocationResponse { location, .. }) => Ok(location),
other => Err(MuxError::protocol(format!(
"unexpected response to {operation}: {other:?}"
))),
}
}
fn expect_ok(response: ServerResponse, operation: &str) -> Result<()> {
match response {
ServerResponse::Ok(_) => Ok(()),
other => Err(MuxError::protocol(format!(
"unexpected response to {operation}: {other:?}"
))),
}
}
fn expect_buffer_with_location(
response: ServerResponse,
operation: &str,
) -> Result<(embers_protocol::BufferRecord, BufferLocation, bool)> {
match response {
ServerResponse::BufferWithLocation(response) => {
let (_, buffer, location, at_root_tab) = response.into_parts();
if buffer.id != location.buffer_id {
return Err(MuxError::protocol(format!(
"{operation} returned buffer {} but location was for buffer {}",
buffer.id, location.buffer_id
)));
}
Ok((buffer, location, at_root_tab))
}
other => Err(MuxError::protocol(format!(
"unexpected response to {operation}: {other:?}"
))),
}
}
fn ensure_matching_buffer_id(
operation: &str,
requested_buffer_id: BufferId,
actual_buffer_id: BufferId,
) -> Result<()> {
if actual_buffer_id == requested_buffer_id {
return Ok(());
}
Err(MuxError::protocol(format!(
"{operation} returned buffer {actual_buffer_id} for requested buffer {requested_buffer_id}"
)))
}
fn format_sessions(sessions: &[SessionRecord]) -> String {
sessions
.iter()
.map(|session| format!("{}\t{}", session.id, session.name))
.collect::<Vec<_>>()
.join("\n")
}
fn format_buffers(buffers: &[embers_protocol::BufferRecord]) -> String {
buffers
.iter()
.map(|buffer| {
let attachment = buffer
.attachment_node_id
.map(|node_id| format!("attached:{node_id}"))
.unwrap_or_else(|| "detached".to_owned());
format!(
"{}\t{}\t{}\t{}",
buffer.id,
buffer_state_label(buffer.state),
attachment,
buffer.title
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn format_clients(clients: &[ClientRecord], sessions: &[SessionRecord]) -> String {
clients
.iter()
.map(|client| {
let current = client
.current_session_id
.map(|session_id| session_label(sessions, session_id))
.unwrap_or_else(|| "-".to_owned());
let scope = if client.subscribed_all_sessions {
"all".to_owned()
} else if client.subscribed_session_ids.is_empty() {
"-".to_owned()
} else {
client
.subscribed_session_ids
.iter()
.map(|session_id| session_label(sessions, *session_id))
.collect::<Vec<_>>()
.join(",")
};
format!("{}\t{}\t{}", client.id, current, scope)
})
.collect::<Vec<_>>()
.join("\n")
}
fn format_buffer_details(
buffer: &embers_protocol::BufferRecord,
location: &BufferLocation,
) -> String {
let mut lines = vec![
format!("id\t{}", buffer.id),
format!(
"title\t{}",
serde_json::to_string(&buffer.title).expect("buffer titles serialize to JSON")
),
format!("state\t{}", buffer_state_label(buffer.state)),
format!("kind\t{}", buffer_kind_label(buffer.kind)),
format!("read_only\t{}", usize::from(buffer.read_only)),
format!("location\t{}", format_buffer_location_inline(location)),
];
if let Some(source_buffer_id) = buffer.helper_source_buffer_id {
lines.push(format!("source_buffer\t{}", source_buffer_id));
}
if let Some(scope) = buffer.helper_scope {
lines.push(format!("history_scope\t{}", history_scope_label(scope)));
}
if !buffer.command.is_empty() {
let serialized_args =
serde_json::to_string(&buffer.command).expect("buffer commands serialize to JSON");
lines.push(format!("command\t{serialized_args}"));
}
if let Some(name) = buffer.process_name() {
let serialized_name =
serde_json::to_string(&name).expect("program names serialize to JSON");
lines.push(format!("current_command\t{serialized_name}"));
}
if let Some(foreground) = &buffer.foreground_command {
let serialized_foreground =
serde_json::to_string(foreground).expect("foreground commands serialize to JSON");
lines.push(format!("foreground_command\t{serialized_foreground}"));
}
if let Some(cwd) = &buffer.cwd {
let serialized_cwd =
serde_json::to_string(cwd).expect("buffer working directories serialize to JSON");
lines.push(format!("cwd\t{serialized_cwd}"));
}
if let Some(pipe) = &buffer.pipe {
lines.push(format!(
"pipe_state\t{}",
buffer_pipe_state_label(pipe.state)
));
let serialized_command =
serde_json::to_string(&pipe.command).expect("buffer pipe commands serialize to JSON");
lines.push(format!("pipe_command\t{serialized_command}"));
if let Some(pid) = pipe.pid {
lines.push(format!("pipe_pid\t{pid}"));
}
if let Some(exit_code) = pipe.exit_code {
lines.push(format!("pipe_exit_code\t{exit_code}"));
}
if let Some(reason) = pipe.stop_reason {
lines.push(format!(
"pipe_stop_reason\t{}",
buffer_pipe_stop_reason_label(reason)
));
}
}
lines.join("\n")
}
fn format_buffer_pipe_details(
buffer_id: BufferId,
pipe: Option<&BufferPipeRecord>,
) -> Result<String> {
let Some(pipe) = pipe else {
return Err(MuxError::not_found(format!(
"buffer {buffer_id} has no pipe state"
)));
};
let mut lines = vec![
format!("buffer_id\t{buffer_id}"),
format!("state\t{}", buffer_pipe_state_label(pipe.state)),
format!(
"command\t{}",
serde_json::to_string(&pipe.command).expect("buffer pipe commands serialize to JSON")
),
];
if let Some(pid) = pipe.pid {
lines.push(format!("pid\t{pid}"));
}
if let Some(exit_code) = pipe.exit_code {
lines.push(format!("exit_code\t{exit_code}"));
}
if let Some(reason) = pipe.stop_reason {
lines.push(format!(
"stop_reason\t{}",
buffer_pipe_stop_reason_label(reason)
));
}
Ok(lines.join("\n"))
}
fn format_buffer_location_line(location: &BufferLocation) -> String {
format!(
"{}\t{}",
location.buffer_id,
format_buffer_location_value(location)
)
}
fn format_buffer_location_inline(location: &BufferLocation) -> String {
match location.attachment {
BufferLocationAttachment::Floating {
session_id,
node_id,
floating_id,
} => {
format!("session:{session_id} node:{node_id} floating:{floating_id}")
}
BufferLocationAttachment::Session {
session_id,
node_id,
} => format!("session:{session_id} node:{node_id}"),
BufferLocationAttachment::Detached => "detached".to_owned(),
}
}
fn format_buffer_location_value(location: &BufferLocation) -> String {
match location.attachment {
BufferLocationAttachment::Floating {
session_id,
node_id,
floating_id,
} => {
format!("session:{session_id}\tnode:{node_id}\tfloating:{floating_id}")
}
BufferLocationAttachment::Session {
session_id,
node_id,
} => format!("session:{session_id}\tnode:{node_id}"),
BufferLocationAttachment::Detached => "detached".to_owned(),
}
}
fn session_label(sessions: &[SessionRecord], session_id: SessionId) -> String {
sessions
.iter()
.find(|session| session.id == session_id)
.map(|session| format!("{}:{}", session.id, session.name))
.unwrap_or_else(|| session_id.to_string())
}
fn format_windows(snapshot: &SessionSnapshot, base_index: Option<u32>) -> Result<String> {
let (_, tabs) = root_tabs(snapshot)?;
Ok(tabs
.tabs
.iter()
.enumerate()
.map(|(index, tab)| {
format!(
"{}\t{}\t{}",
window_number(index, base_index),
usize::from(u32::try_from(index).ok() == Some(tabs.active)),
tab.title
)
})
.collect::<Vec<_>>()
.join("\n"))
}
fn format_panes(snapshot: &SessionSnapshot, pane_ids: &[NodeId]) -> Result<String> {
pane_ids
.iter()
.map(|pane_id| {
let leaf = node_record(snapshot, *pane_id)?;
let buffer_id = leaf
.buffer_view
.as_ref()
.map(|view| view.buffer_id)
.ok_or_else(|| MuxError::invalid_input(format!("node {pane_id} is not a pane")))?;
let buffer = buffer_record(snapshot, buffer_id)?;
Ok(format!(
"{}\t{}\t{}\t{}",
pane_id,
buffer.id,
usize::from(snapshot.session.focused_leaf_id == Some(*pane_id)),
buffer.title
))
})
.collect::<Result<Vec<_>>>()
.map(|lines| lines.join("\n"))
}
fn pane_showing(snapshot: &SessionSnapshot, buffer_id: BufferId) -> Option<NodeId> {
snapshot
.nodes
.iter()
.find(|node| {
node.buffer_view
.as_ref()
.is_some_and(|view| view.buffer_id == buffer_id)
})
.map(|node| node.id)
}
fn window_children(snapshot: &SessionSnapshot) -> Result<Vec<NodeId>> {
let (_, tabs) = root_tabs(snapshot)?;
Ok(tabs.tabs.iter().map(|tab| tab.child_id).collect())
}
fn window_panes(snapshot: &SessionSnapshot) -> Result<Vec<Option<NodeId>>> {
window_children(snapshot)?
.into_iter()
.map(|child_id| {
let leaves = visible_leaf_ids(snapshot, child_id)?;
Ok(window_active_pane(snapshot, child_id, &leaves))
})
.collect()
}
fn window_active_pane(
snapshot: &SessionSnapshot,
child_id: NodeId,
leaves: &[NodeId],
) -> Option<NodeId> {
snapshot
.session
.focused_leaf_id
.filter(|leaf_id| leaves.contains(leaf_id))
.or_else(|| {
node_record(snapshot, child_id)
.ok()
.and_then(|node| node.focus_leaf_id)
.filter(|leaf_id| leaves.contains(leaf_id))
})
.or_else(|| leaves.first().copied())
}
fn current_root_window(snapshot: &SessionSnapshot) -> Result<Option<u32>> {
let (_, tabs) = root_tabs(snapshot)?;
Ok((!tabs.tabs.is_empty()).then_some(tabs.active))
}
async fn restore_current(
connection: &mut CliConnection,
mut snapshot: SessionSnapshot,
window: Option<u32>,
leaf: Option<NodeId>,
context: &str,
) -> Result<SessionSnapshot> {
let session_id = snapshot.session.id;
if let Some(index) = window {
let response = connection
.request(ClientMessage::Session(SessionRequest::SelectRootTab {
request_id: new_request_id(),
session_id,
index,
}))
.await?;
snapshot = expect_session_snapshot(response, context)?;
}
if let Some(leaf) = leaf.filter(|leaf| snapshot.session.focused_leaf_id != Some(*leaf)) {
let response = connection
.request(ClientMessage::Node(NodeRequest::Focus {
request_id: new_request_id(),
session_id,
node_id: leaf,
}))
.await?;
snapshot = expect_session_snapshot(response, context)?;
}
Ok(snapshot)
}
fn active_root_window(snapshot: &SessionSnapshot) -> Result<(u32, String)> {
let (_, tabs) = root_tabs(snapshot)?;
let tab = protocol_tab(tabs, tabs.active)
.ok_or_else(|| MuxError::protocol("session root tabs has invalid active index"))?;
Ok((tabs.active, tab.title.clone()))
}
fn plan_directional_resize(
pane: &ResolvedPane,
direction: NavigationDirection,
cells: u16,
) -> Result<Option<SplitResize>> {
let session_id = pane.snapshot.session.id;
let mut state = ClientState::default();
state.apply_session_snapshot(pane.snapshot.clone());
let viewport = drawn_viewport(&state, session_id).ok_or_else(|| {
MuxError::invalid_input("cannot determine the size the session is drawn at")
})?;
let presentation = PresentationModel::project_with(
&state,
session_id,
viewport,
PaneTitles::reported(&pane.snapshot.session),
)?;
Ok(presentation.resize_leaf(&state, pane.leaf_id, direction, cells))
}
fn root_tabs(
snapshot: &SessionSnapshot,
) -> Result<(&embers_protocol::NodeRecord, &embers_protocol::TabsRecord)> {
let node = node_record(snapshot, snapshot.session.root_node_id)?;
let tabs = node.tabs.as_ref().ok_or_else(|| {
MuxError::protocol(format!(
"session {} root node {} is not tabs",
snapshot.session.id, node.id
))
})?;
Ok((node, tabs))
}
fn node_record(
snapshot: &SessionSnapshot,
node_id: NodeId,
) -> Result<&embers_protocol::NodeRecord> {
snapshot
.nodes
.iter()
.find(|node| node.id == node_id)
.ok_or_else(|| MuxError::not_found(format!("node {node_id} is not present in snapshot")))
}
fn buffer_record(
snapshot: &SessionSnapshot,
buffer_id: BufferId,
) -> Result<&embers_protocol::BufferRecord> {
snapshot
.buffers
.iter()
.find(|buffer| buffer.id == buffer_id)
.ok_or_else(|| {
MuxError::not_found(format!("buffer {buffer_id} is not present in snapshot"))
})
}
fn floating_record(snapshot: &SessionSnapshot, floating_id: FloatingId) -> Result<&FloatingRecord> {
snapshot
.floating
.iter()
.find(|floating| floating.id == floating_id)
.ok_or_else(|| {
MuxError::not_found(format!("popup {floating_id} is not present in snapshot"))
})
}
fn visible_leaf_ids(snapshot: &SessionSnapshot, node_id: NodeId) -> Result<Vec<NodeId>> {
let node = node_record(snapshot, node_id)?;
match node.kind {
embers_protocol::NodeRecordKind::BufferView => Ok(vec![node.id]),
embers_protocol::NodeRecordKind::Split => {
let split = node
.split
.as_ref()
.ok_or_else(|| MuxError::protocol(format!("split node {node_id} is malformed")))?;
let mut leaves = Vec::new();
for child_id in &split.child_ids {
leaves.extend(visible_leaf_ids(snapshot, *child_id)?);
}
Ok(leaves)
}
embers_protocol::NodeRecordKind::Tabs => {
let tabs = node
.tabs
.as_ref()
.ok_or_else(|| MuxError::protocol(format!("tabs node {node_id} is malformed")))?;
let active_child = protocol_tab(tabs, tabs.active).ok_or_else(|| {
MuxError::protocol(format!("tabs node {node_id} has invalid active index"))
})?;
visible_leaf_ids(snapshot, active_child.child_id)
}
}
}
fn resolve_window_index(
tabs: &embers_protocol::TabsRecord,
selector: Option<&str>,
base_index: Option<u32>,
) -> Result<u32> {
let Some(selector) = selector else {
return Ok(tabs.active);
};
if let Some(base_index) = base_index
&& let Ok(number) = selector.parse::<u64>()
{
return window_position(number, tabs.tabs.len(), base_index)
.map(|position| u32::try_from(position).expect("tab index fits into protocol width"))
.ok_or_else(|| {
MuxError::not_found(format!("window index '{selector}' is out of range"))
});
}
if let Ok(index) = selector.parse::<u32>() {
let mut candidates = Vec::new();
if protocol_tab(tabs, index).is_some() {
candidates.push(index);
}
if let Some(one_based) = index.checked_sub(1)
&& protocol_tab(tabs, one_based).is_some()
{
candidates.push(one_based);
}
candidates.sort_unstable();
candidates.dedup();
return match candidates.as_slice() {
[only] => Ok(*only),
[] => Err(MuxError::not_found(format!(
"window index '{selector}' is out of range"
))),
_ => Err(MuxError::invalid_input(format!(
"window index '{selector}' is ambiguous between 0-based and 1-based addressing"
))),
};
}
let matches = tabs
.tabs
.iter()
.enumerate()
.filter(|(_, tab)| tab.title == selector)
.map(|(index, _)| u32::try_from(index).expect("tab index fits into protocol width"))
.collect::<Vec<_>>();
match matches.as_slice() {
[index] => Ok(*index),
[] => Err(MuxError::not_found(format!(
"window '{selector}' was not found"
))),
_ => Err(MuxError::conflict(format!(
"window title '{selector}' matched multiple root tabs"
))),
}
}
fn protocol_tab(
tabs: &embers_protocol::TabsRecord,
index: u32,
) -> Option<&embers_protocol::TabRecord> {
usize::try_from(index)
.ok()
.and_then(|index| tabs.tabs.get(index))
}
fn split_scoped_target(target: Option<&str>) -> (Option<String>, Option<String>) {
match target {
Some(target) => {
if let Some((session, selector)) = target.split_once(':') {
(Some(session.to_owned()), Some(selector.to_owned()))
} else {
(None, Some(target.to_owned()))
}
}
None => (None, None),
}
}
fn buffer_state_label(state: embers_protocol::BufferRecordState) -> &'static str {
match state {
embers_protocol::BufferRecordState::Created => "created",
embers_protocol::BufferRecordState::Running => "running",
embers_protocol::BufferRecordState::Interrupted => "interrupted",
embers_protocol::BufferRecordState::Exited => "exited",
}
}
fn buffer_kind_label(kind: embers_protocol::BufferRecordKind) -> &'static str {
match kind {
embers_protocol::BufferRecordKind::Pty => "pty",
embers_protocol::BufferRecordKind::Helper => "helper",
}
}
fn buffer_pipe_state_label(state: BufferPipeState) -> &'static str {
match state {
BufferPipeState::Running => "running",
BufferPipeState::Stopped => "stopped",
}
}
fn buffer_pipe_stop_reason_label(reason: BufferPipeStopReason) -> &'static str {
match reason {
BufferPipeStopReason::Requested => "requested",
BufferPipeStopReason::PipeExited => "pipe_exited",
BufferPipeStopReason::WriteFailed => "write_failed",
BufferPipeStopReason::BufferExited => "buffer_exited",
BufferPipeStopReason::RuntimeInterrupted => "runtime_interrupted",
}
}
fn history_scope_label(scope: BufferHistoryScope) -> &'static str {
match scope {
BufferHistoryScope::Full => "full",
BufferHistoryScope::Visible => "visible",
}
}
fn history_scope(scope: HistoryScopeArg) -> BufferHistoryScope {
match scope {
HistoryScopeArg::Full => BufferHistoryScope::Full,
HistoryScopeArg::Visible => BufferHistoryScope::Visible,
}
}
fn history_placement(placement: HistoryPlacementArg) -> BufferHistoryPlacement {
match placement {
HistoryPlacementArg::Tab => BufferHistoryPlacement::Tab,
HistoryPlacementArg::Floating => BufferHistoryPlacement::Floating,
}
}
fn ensure_history_attachment(
buffer_id: BufferId,
requested_placement: BufferHistoryPlacement,
location: &BufferLocation,
) -> Result<()> {
match (requested_placement, &location.attachment) {
(BufferHistoryPlacement::Tab, BufferLocationAttachment::Session { .. })
| (BufferHistoryPlacement::Floating, BufferLocationAttachment::Floating { .. }) => Ok(()),
(_, BufferLocationAttachment::Detached) => Err(MuxError::protocol(format!(
"buffer history returned detached helper location for buffer {buffer_id}"
))),
(BufferHistoryPlacement::Tab, BufferLocationAttachment::Floating { .. }) => {
Err(MuxError::protocol(format!(
"buffer history returned unexpected attachment for buffer {buffer_id}: expected Tab"
)))
}
(BufferHistoryPlacement::Floating, BufferLocationAttachment::Session { .. }) => {
Err(MuxError::protocol(format!(
"buffer history returned unexpected attachment for buffer {buffer_id}: expected Floating"
)))
}
}
}
fn ensure_history_response(
source_buffer_id: BufferId,
requested_scope: BufferHistoryScope,
requested_placement: BufferHistoryPlacement,
buffer: &embers_protocol::BufferRecord,
location: &BufferLocation,
at_root_tab: bool,
) -> Result<()> {
if buffer.kind != embers_protocol::BufferRecordKind::Helper {
return Err(MuxError::protocol(format!(
"buffer history returned buffer {} with unexpected kind {:?}",
buffer.id, buffer.kind
)));
}
if buffer.helper_source_buffer_id != Some(source_buffer_id) {
return Err(MuxError::protocol(format!(
"buffer history returned helper {} for source {:?} instead of {source_buffer_id}",
buffer.id, buffer.helper_source_buffer_id
)));
}
if buffer.helper_scope != Some(requested_scope) {
return Err(MuxError::protocol(format!(
"buffer history returned helper {} with scope {:?} instead of {:?}",
buffer.id, buffer.helper_scope, requested_scope
)));
}
let (session_id, node_id) = match &location.attachment {
BufferLocationAttachment::Session {
session_id,
node_id,
}
| BufferLocationAttachment::Floating {
session_id,
node_id,
..
} => (*session_id, *node_id),
BufferLocationAttachment::Detached => {
ensure_history_attachment(source_buffer_id, requested_placement, location)?;
unreachable!("detached buffer history attachments should fail validation")
}
};
if buffer.attachment_node_id != Some(node_id) {
return Err(MuxError::protocol(format!(
"buffer history returned helper {} attached to {:?} but location pointed at {node_id}",
buffer.id, buffer.attachment_node_id
)));
}
ensure_history_attachment(source_buffer_id, requested_placement, location)?;
if matches!(requested_placement, BufferHistoryPlacement::Tab) && !at_root_tab {
return Err(MuxError::protocol(format!(
"buffer history returned helper node {node_id} outside session {session_id} root tabs"
)));
}
Ok(())
}
fn break_destination(destination: BreakDestinationArg) -> NodeBreakDestination {
match destination {
BreakDestinationArg::Tab => NodeBreakDestination::Tab,
BreakDestinationArg::Floating => NodeBreakDestination::Floating,
}
}
fn join_placement(placement: JoinPlacementArg) -> NodeJoinPlacement {
match placement {
JoinPlacementArg::Left => NodeJoinPlacement::Left,
JoinPlacementArg::Right => NodeJoinPlacement::Right,
JoinPlacementArg::Up => NodeJoinPlacement::Up,
JoinPlacementArg::Down => NodeJoinPlacement::Down,
JoinPlacementArg::TabBefore => NodeJoinPlacement::TabBefore,
JoinPlacementArg::TabAfter => NodeJoinPlacement::TabAfter,
}
}
fn split_scoped_required(target: &str, label: &str) -> Result<(Option<String>, String)> {
let (session, selector) = split_scoped_target(Some(target));
let selector =
selector.ok_or_else(|| MuxError::invalid_input(format!("{label} is required")))?;
Ok((session, selector))
}
fn parse_floating_id(raw: &str) -> Result<FloatingId> {
raw.parse::<u64>().map(FloatingId).map_err(|_| {
MuxError::invalid_input(format!("popup target '{raw}' is not a valid popup id"))
})
}
fn spawn_size(snapshot: &SessionSnapshot, placement: PanePlacement) -> Option<PtySize> {
let viewport = snapshot.session.viewport?;
let pane_titles = PaneTitles::reported(&snapshot.session);
let mut state = ClientState::default();
state.apply_session_snapshot(snapshot.clone());
spawn_pty_size(
&state,
snapshot.session.id,
viewport,
pane_titles,
placement,
)
}
fn buffer_command(command: Vec<String>) -> Vec<String> {
if command.is_empty() {
vec![std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned())]
} else {
command
}
}
fn default_title(command: &[String], fallback: &str) -> String {
command
.first()
.and_then(|value| {
Path::new(value)
.file_name()
.and_then(|name| name.to_str())
.map(str::to_owned)
})
.unwrap_or_else(|| fallback.to_owned())
}
#[cfg(test)]
mod tests {
#[cfg(windows)]
use base64::Engine as _;
use clap::{Parser, error::ErrorKind};
use embers_core::{ActivityState, BufferId, FloatingId, NodeId, PtySize, SessionId};
use embers_protocol::{
BufferLocation, BufferRecord, BufferRecordKind, BufferRecordState, TabRecord, TabsRecord,
};
#[cfg(windows)]
use std::ffi::OsString;
#[cfg(unix)]
use std::ffi::OsString;
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
#[cfg(windows)]
use std::os::windows::ffi::OsStringExt;
use std::path::Path;
use super::{
BreakDestinationArg, BufferCommand, Cli, Command, HistoryPlacementArg, HistoryScopeArg,
JoinPlacementArg, NodeCommand, ensure_history_attachment, format_buffer_details,
pane_buffer, resolve_window_index, split_scoped_required, split_scoped_target,
};
#[test]
fn pane_buffer_is_only_taken_for_the_panes_own_server() {
let socket = Path::new("/run/embers/embers.sock");
let same = Some(std::ffi::OsStr::new("/run/embers/embers.sock"));
let other = Some(std::ffi::OsStr::new("/run/embers/other.sock"));
assert_eq!(pane_buffer(Some("7"), same, socket), Some(BufferId(7)));
assert_eq!(pane_buffer(Some("7"), other, socket), None);
assert_eq!(pane_buffer(Some("7"), None, socket), None);
assert_eq!(pane_buffer(None, same, socket), None);
assert_eq!(pane_buffer(Some("%7"), same, socket), None);
}
#[test]
fn parser_accepts_global_socket_after_subcommand() {
let cli = Cli::try_parse_from([
"embers",
"new-window",
"--socket",
"/tmp/mux.sock",
"--title",
"logs",
"--",
"/bin/sh",
])
.expect("cli parses");
match cli.command {
Some(super::Command::NewWindow { title, command, .. }) => {
assert_eq!(title.as_deref(), Some("logs"));
assert_eq!(command, vec!["/bin/sh"]);
}
other => panic!("expected new-window command, got {other:?}"),
}
}
#[test]
fn runtime_keeper_uses_distinct_keeper_socket_flag() {
let cli = Cli::try_parse_from([
"embers",
"__runtime-keeper",
"--socket",
"/tmp/global.sock",
"--keeper-socket",
"/tmp/keeper.sock",
"--cols",
"80",
"--rows",
"24",
"--",
"/bin/sh",
])
.expect("cli parses");
assert_eq!(cli.socket.as_deref(), Some(Path::new("/tmp/global.sock")));
match cli.command {
Some(super::Command::RuntimeKeeper {
keeper_socket,
cols,
rows,
command,
..
}) => {
assert_eq!(keeper_socket, Path::new("/tmp/keeper.sock"));
assert_eq!((cols, rows), (80, 24));
assert_eq!(command, vec!["/bin/sh"]);
}
other => panic!("expected runtime keeper command, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn runtime_keeper_env_values_decode_base64_losslessly() {
let (key, value) = super::parse_env_arg("KEY=base64:AP8=").expect("env parses");
assert_eq!(key, "KEY");
assert_eq!(value, OsString::from_vec(vec![0, 255]));
}
#[cfg(windows)]
#[test]
fn runtime_keeper_env_values_decode_utf16le_losslessly() {
let encoded = base64::engine::general_purpose::STANDARD.encode([0x00, 0xD8, 0x61, 0x00]);
let (key, value) =
super::parse_env_arg(&format!("KEY=base64:{encoded}")).expect("env parses");
assert_eq!(key, "KEY");
assert_eq!(value, OsString::from_wide(&[0xD800, 0x0061]));
}
#[test]
fn scoped_targets_split_session_prefix() {
assert_eq!(
split_scoped_required("main:2", "window target").expect("target parses"),
(Some("main".to_owned()), "2".to_owned())
);
assert_eq!(split_scoped_target(Some("3")), (None, Some("3".to_owned())));
}
#[test]
fn numeric_window_indices_report_ambiguity() {
let tabs = TabsRecord {
active: 0,
tabs: vec![
TabRecord {
title: "one".to_owned(),
child_id: NodeId(1),
synchronize_panes: false,
},
TabRecord {
title: "two".to_owned(),
child_id: NodeId(2),
synchronize_panes: false,
},
TabRecord {
title: "three".to_owned(),
child_id: NodeId(3),
synchronize_panes: false,
},
],
};
let error = resolve_window_index(&tabs, Some("1"), None).expect_err("index is ambiguous");
assert!(
error
.to_string()
.contains("ambiguous between 0-based and 1-based")
);
assert_eq!(
resolve_window_index(&tabs, Some("0"), None).expect("zero resolves"),
0
);
}
#[test]
fn numeric_window_indices_count_from_a_set_base_index() {
let tabs = TabsRecord {
active: 0,
tabs: ["one", "two"]
.into_iter()
.enumerate()
.map(|(index, title)| TabRecord {
title: title.to_owned(),
child_id: NodeId(index as u64 + 1),
synchronize_panes: false,
})
.collect(),
};
assert_eq!(resolve_window_index(&tabs, Some("1"), Some(1)).unwrap(), 0);
assert_eq!(resolve_window_index(&tabs, Some("2"), Some(1)).unwrap(), 1);
assert!(resolve_window_index(&tabs, Some("0"), Some(1)).is_err());
assert_eq!(resolve_window_index(&tabs, Some("1"), Some(0)).unwrap(), 1);
assert_eq!(
resolve_window_index(&tabs, Some("two"), Some(1)).unwrap(),
1
);
}
#[test]
fn buffer_details_location_row_uses_single_tab_delimiter() {
let details = format_buffer_details(
&BufferRecord {
id: BufferId(7),
title: "logs".to_owned(),
command: vec!["/bin/sh".to_owned()],
cwd: Some("/tmp".to_owned()),
kind: BufferRecordKind::Pty,
pid: None,
env: Default::default(),
state: BufferRecordState::Running,
attachment_node_id: Some(NodeId(3)),
read_only: false,
helper_source_buffer_id: None,
helper_scope: None,
pty_size: PtySize::new(80, 24),
activity: ActivityState::Idle,
last_snapshot_seq: 0,
exit_code: None,
pipe: None,
user_options: Default::default(),
foreground_command: None,
},
&BufferLocation::session(BufferId(7), SessionId(1), NodeId(3)),
);
let location_line = details
.lines()
.find(|line| line.starts_with("location\t"))
.expect("location row present");
assert_eq!(location_line.matches('\t').count(), 1);
}
#[test]
fn buffer_details_command_row_preserves_argument_boundaries() {
let details = format_buffer_details(
&BufferRecord {
id: BufferId(8),
title: "script".to_owned(),
command: vec![
"/bin/sh".to_owned(),
"-lc".to_owned(),
"printf '%s\\n' 'hello world'".to_owned(),
],
cwd: None,
kind: BufferRecordKind::Pty,
pid: None,
env: Default::default(),
state: BufferRecordState::Running,
attachment_node_id: Some(NodeId(4)),
read_only: false,
helper_source_buffer_id: None,
helper_scope: None,
pty_size: PtySize::new(80, 24),
activity: ActivityState::Idle,
last_snapshot_seq: 0,
exit_code: None,
pipe: None,
user_options: Default::default(),
foreground_command: None,
},
&BufferLocation::session(BufferId(8), SessionId(1), NodeId(4)),
);
let command_line = details
.lines()
.find(|line| line.starts_with("command\t"))
.expect("command row present");
let serialized_args = command_line
.strip_prefix("command\t")
.expect("command row prefix present");
let command: Vec<String> =
serde_json::from_str(serialized_args).expect("command row uses JSON");
assert_eq!(
command,
vec![
"/bin/sh".to_owned(),
"-lc".to_owned(),
"printf '%s\\n' 'hello world'".to_owned(),
]
);
}
#[test]
fn buffer_details_show_the_foreground_command() {
let buffer = |foreground_command: Option<Vec<String>>| BufferRecord {
id: BufferId(10),
title: "shell".to_owned(),
command: vec!["/bin/zsh".to_owned()],
cwd: None,
kind: BufferRecordKind::Pty,
pid: None,
env: Default::default(),
state: BufferRecordState::Running,
attachment_node_id: Some(NodeId(6)),
read_only: false,
helper_source_buffer_id: None,
helper_scope: None,
pty_size: PtySize::new(80, 24),
activity: ActivityState::Idle,
last_snapshot_seq: 0,
exit_code: None,
pipe: None,
user_options: Default::default(),
foreground_command,
};
let location = BufferLocation::session(BufferId(10), SessionId(1), NodeId(6));
let row = |details: &str, name: &str| {
details
.lines()
.find_map(|line| line.strip_prefix(&format!("{name}\t")))
.map(str::to_owned)
};
let details = format_buffer_details(
&buffer(Some(vec!["nvim".to_owned(), "notes.txt".to_owned()])),
&location,
);
assert_eq!(
row(&details, "current_command").as_deref(),
Some("\"nvim\"")
);
let foreground: Vec<String> = serde_json::from_str(
&row(&details, "foreground_command").expect("foreground_command row present"),
)
.expect("foreground_command row uses JSON");
assert_eq!(foreground, ["nvim", "notes.txt"]);
let details = format_buffer_details(&buffer(None), &location);
assert_eq!(row(&details, "current_command").as_deref(), Some("\"zsh\""));
assert_eq!(row(&details, "foreground_command"), None);
}
#[test]
fn buffer_details_title_and_cwd_rows_use_json() {
let details = format_buffer_details(
&BufferRecord {
id: BufferId(9),
title: "build\tlogs\nstderr".to_owned(),
command: Vec::new(),
cwd: Some("/tmp/work\tspace\nhere".to_owned()),
kind: BufferRecordKind::Pty,
pid: None,
env: Default::default(),
state: BufferRecordState::Running,
attachment_node_id: Some(NodeId(5)),
read_only: false,
helper_source_buffer_id: None,
helper_scope: None,
pty_size: PtySize::new(80, 24),
activity: ActivityState::Idle,
last_snapshot_seq: 0,
exit_code: None,
pipe: None,
user_options: Default::default(),
foreground_command: None,
},
&BufferLocation::session(BufferId(9), SessionId(1), NodeId(5)),
);
let title_line = details
.lines()
.find(|line| line.starts_with("title\t"))
.expect("title row present");
let cwd_line = details
.lines()
.find(|line| line.starts_with("cwd\t"))
.expect("cwd row present");
let title = title_line
.strip_prefix("title\t")
.expect("title row prefix present");
let cwd = cwd_line
.strip_prefix("cwd\t")
.expect("cwd row prefix present");
assert_eq!(
serde_json::from_str::<String>(title).expect("title row uses JSON"),
"build\tlogs\nstderr"
);
assert_eq!(
serde_json::from_str::<String>(cwd).expect("cwd row uses JSON"),
"/tmp/work\tspace\nhere"
);
}
#[test]
fn buffer_subcommands_parse_expected_flags_and_defaults() {
let history =
Cli::try_parse_from(["embers", "buffer", "history", "7"]).expect("history parses");
match history.command {
Some(Command::Buffer {
command:
BufferCommand::History {
buffer_id,
scope,
placement,
client,
},
}) => {
assert_eq!(buffer_id, 7);
assert!(matches!(scope, HistoryScopeArg::Full));
assert!(matches!(placement, HistoryPlacementArg::Tab));
assert_eq!(client, None);
}
other => panic!("expected buffer history command, got {other:?}"),
}
let history_with_flags = Cli::try_parse_from([
"embers",
"buffer",
"history",
"9",
"--scope",
"visible",
"--placement",
"floating",
"--client",
"5",
])
.expect("history flags parse");
match history_with_flags.command {
Some(Command::Buffer {
command:
BufferCommand::History {
buffer_id,
scope,
placement,
client,
},
}) => {
assert_eq!(buffer_id, 9);
assert!(matches!(scope, HistoryScopeArg::Visible));
assert!(matches!(placement, HistoryPlacementArg::Floating));
assert_eq!(client.map(std::num::NonZeroU64::get), Some(5));
}
other => panic!("expected flagged buffer history command, got {other:?}"),
}
let reveal = Cli::try_parse_from(["embers", "buffer", "reveal", "11", "--client", "6"])
.expect("reveal parses");
match reveal.command {
Some(Command::Buffer {
command: BufferCommand::Reveal { buffer_id, client },
}) => {
assert_eq!(buffer_id, 11);
assert_eq!(client.map(std::num::NonZeroU64::get), Some(6));
}
other => panic!("expected buffer reveal command, got {other:?}"),
}
let set_option =
Cli::try_parse_from(["embers", "buffer", "set-option", "-t", "p1", "is-vim", "1"])
.expect("set-option parses");
match set_option.command {
Some(Command::Buffer {
command:
BufferCommand::SetOption {
target,
key,
value,
unset,
},
}) => {
assert_eq!(target.as_deref(), Some("p1"));
assert_eq!(key, "is-vim");
assert_eq!(value.as_deref(), Some("1"));
assert!(!unset);
}
other => panic!("expected buffer set-option command, got {other:?}"),
}
let unset = Cli::try_parse_from(["embers", "buffer", "set-option", "--unset", "is-vim"])
.expect("set-option --unset parses");
match unset.command {
Some(Command::Buffer {
command:
BufferCommand::SetOption {
target,
key,
value,
unset,
},
}) => {
assert_eq!(target, None);
assert_eq!(key, "is-vim");
assert_eq!(value, None);
assert!(unset);
}
other => panic!("expected buffer set-option --unset command, got {other:?}"),
}
let no_value = Cli::try_parse_from(["embers", "buffer", "set-option", "is-vim"])
.expect("set-option without a value still parses");
assert!(matches!(
no_value.command,
Some(Command::Buffer {
command: BufferCommand::SetOption {
value: None,
unset: false,
..
},
})
));
assert!(
Cli::try_parse_from(["embers", "buffer", "set-option", "--unset", "is-vim", "1"])
.is_err(),
"--unset combined with a value should be rejected"
);
let show_options = Cli::try_parse_from(["embers", "buffer", "show-options", "-t", "p2"])
.expect("show-options parses");
match show_options.command {
Some(Command::Buffer {
command: BufferCommand::ShowOptions { target },
}) => assert_eq!(target.as_deref(), Some("p2")),
other => panic!("expected buffer show-options command, got {other:?}"),
}
let show_options_default = Cli::try_parse_from(["embers", "buffer", "show-options"])
.expect("show-options without a target parses");
assert!(matches!(
show_options_default.command,
Some(Command::Buffer {
command: BufferCommand::ShowOptions { target: None },
})
));
}
#[test]
fn history_attachment_validation_accepts_matching_locations() {
assert!(
ensure_history_attachment(
BufferId(7),
embers_protocol::BufferHistoryPlacement::Tab,
&BufferLocation::session(BufferId(70), SessionId(1), NodeId(3)),
)
.is_ok()
);
assert!(
ensure_history_attachment(
BufferId(7),
embers_protocol::BufferHistoryPlacement::Floating,
&BufferLocation::floating(BufferId(71), SessionId(1), NodeId(4), FloatingId(5)),
)
.is_ok()
);
}
#[test]
fn history_attachment_validation_rejects_mismatched_locations() {
let floating_error = ensure_history_attachment(
BufferId(7),
embers_protocol::BufferHistoryPlacement::Floating,
&BufferLocation::session(BufferId(70), SessionId(1), NodeId(3)),
)
.expect_err("floating history should reject tab helper");
assert!(floating_error.to_string().contains("expected Floating"));
let tab_error = ensure_history_attachment(
BufferId(7),
embers_protocol::BufferHistoryPlacement::Tab,
&BufferLocation::floating(BufferId(71), SessionId(1), NodeId(4), FloatingId(5)),
)
.expect_err("tab history should reject floating helper");
assert!(tab_error.to_string().contains("expected Tab"));
}
#[test]
fn node_subcommands_parse_expected_flags_and_reject_legacy_shortcuts() {
let break_to_floating =
Cli::try_parse_from(["embers", "node", "break", "11", "--to", "floating"])
.expect("break parses");
match break_to_floating.command {
Some(Command::Node {
command:
NodeCommand::Break {
node_id,
destination,
},
}) => {
assert_eq!(node_id, 11);
assert!(matches!(destination, BreakDestinationArg::Floating));
}
other => panic!("expected node break command, got {other:?}"),
}
let join_after = Cli::try_parse_from([
"embers",
"node",
"join-buffer",
"21",
"34",
"--as",
"tab-after",
])
.expect("join-buffer tab-after parses");
match join_after.command {
Some(Command::Node {
command:
NodeCommand::JoinBuffer {
node_id,
buffer_id,
placement,
},
}) => {
assert_eq!(node_id, 21);
assert_eq!(buffer_id, 34);
assert!(matches!(placement, JoinPlacementArg::TabAfter));
}
other => panic!("expected node join-buffer command, got {other:?}"),
}
let join_default = Cli::try_parse_from(["embers", "node", "join-buffer", "21", "34"])
.expect("join-buffer default parses");
match join_default.command {
Some(Command::Node {
command: NodeCommand::JoinBuffer { placement, .. },
}) => {
assert!(matches!(placement, JoinPlacementArg::TabAfter));
}
other => panic!("expected default node join-buffer command, got {other:?}"),
}
let join_before = Cli::try_parse_from([
"embers",
"node",
"join-buffer",
"21",
"34",
"--as",
"tab-before",
])
.expect("join-buffer tab-before parses");
match join_before.command {
Some(Command::Node {
command: NodeCommand::JoinBuffer { placement, .. },
}) => {
assert!(matches!(placement, JoinPlacementArg::TabBefore));
}
other => panic!("expected node join-buffer command, got {other:?}"),
}
let legacy_tab_after =
Cli::try_parse_from(["embers", "node", "join-buffer", "21", "34", "--tab-after"])
.expect_err("legacy --tab-after should be rejected");
assert_eq!(legacy_tab_after.kind(), ErrorKind::UnknownArgument);
let legacy_tab_before =
Cli::try_parse_from(["embers", "node", "join-buffer", "21", "34", "--tab-before"])
.expect_err("legacy --tab-before should be rejected");
assert_eq!(legacy_tab_before.kind(), ErrorKind::UnknownArgument);
}
}