Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ libc = "0.2"
portable-pty = "0.9"
predicates = "3"
proptest = "1"
regex = "1"
rhai = "1"
rhai-autodocs = "0.11"
serde = { version = "1", features = ["derive"] }
Expand Down
30 changes: 30 additions & 0 deletions crates/embers-cli/src/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ pub async fn run(
.map_err(|error| MuxError::invalid_input(error.to_string()))?;
let watched_config_path = config.active_source().path.clone();
let mut configured = ConfiguredClient::new(client, config);
configured.set_socket_path(socket_path.clone());
if let Some(session_id) = session_id {
configured.emit_terminal_title(session_id);
}

let mut terminal = TerminalGuard::enter(mouse_capture_enabled(&configured))?;
let (input_tx, mut input_rx) = mpsc::unbounded_channel();
Expand Down Expand Up @@ -138,6 +142,20 @@ pub async fn run(
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => return Ok(()),
}

// A dispatched action (switch/reveal a buffer or session) may have
// changed the active session inside the client. Reconcile before the
// next queued input is read, so a following key targets the new
// session rather than the old one — not just after the queue drains.
// Detach (active becomes None) is left to the ClientChanged path, and
// a not-yet-initialised None is ignored.
if let Some(active_session_id) = configured.active_session_id()
&& Some(active_session_id) != session_id
{
ensure_root_window(configured.client_mut(), active_session_id).await?;
session_id = Some(active_session_id);
dirty = true;
}
}

let next_size = terminal.size()?;
Expand All @@ -162,10 +180,22 @@ pub async fn run(
}
SwitchedSession::Ignore => {}
}
// handle_event drains background notifications up front, but its
// own awaits (and the session-switch handling above) can race a
// background task pushing one after that drain; surface any such
// notification this frame instead of deferring it a poll.
configured.drain_background_notifications();
terminal.write_bytes(&drain_terminal_output(&mut configured))?;
dirty = true;
}
None => {
// The poll timed out with no event. A background task (e.g. a
// run_shell child) may have failed while we were idle; surface
// it now instead of waiting for the next input or server event.
if configured.drain_background_notifications() {
terminal.write_bytes(&drain_terminal_output(&mut configured))?;
dirty = true;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
continue;
}
}
Expand Down
171 changes: 171 additions & 0 deletions crates/embers-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,18 @@ pub enum BufferCommand {
#[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>,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[derive(Debug, Subcommand)]
Expand Down Expand Up @@ -675,6 +687,84 @@ async fn execute_command(connection: &mut CliConnection, command: Command) -> Re
)?;
Ok(format_buffer_location_line(&location))
}
BufferCommand::SetOption {
target,
key,
value,
unset,
} => {
let buffer_id = connection.resolve_pane(target.as_deref()).await?.buffer_id;
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)",
)
})?)
};
let response = connection
.request(ClientMessage::Buffer(BufferRequest::SetUserOption {
request_id: new_request_id(),
buffer_id,
key,
value,
}))
.await?;
match response {
ServerResponse::Buffer(response) => {
ensure_matching_buffer_id(
"buffer set-option",
buffer_id,
response.buffer.id,
)?;
Ok(String::new())
}
other => Err(MuxError::protocol(format!(
"unexpected response to buffer set-option: {other:?}"
))),
}
}
BufferCommand::ShowOptions { target } => {
let buffer_id = connection.resolve_pane(target.as_deref()).await?.buffer_id;
let response = connection
.request(ClientMessage::Buffer(BufferRequest::Get {
request_id: new_request_id(),
buffer_id,
}))
.await?;
match response {
ServerResponse::Buffer(response) => {
ensure_matching_buffer_id(
"buffer show-options",
buffer_id,
response.buffer.id,
)?;
Ok(response
.buffer
.user_options
.iter()
.map(|(key, value)| {
// JSON-encode both fields and tab-separate them
// (like format_buffer_details) so a key or value
// containing whitespace stays one parseable line
// with an unambiguous field boundary.
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"))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
other => Err(MuxError::protocol(format!(
"unexpected response to buffer show-options: {other:?}"
))),
}
}
},
Command::Node { command } => match command {
NodeCommand::Zoom { node_id } => {
Expand Down Expand Up @@ -2605,6 +2695,7 @@ mod tests {
last_snapshot_seq: 0,
exit_code: None,
pipe: None,
user_options: Default::default(),
},
&BufferLocation::session(BufferId(7), SessionId(1), NodeId(3)),
);
Expand Down Expand Up @@ -2641,6 +2732,7 @@ mod tests {
last_snapshot_seq: 0,
exit_code: None,
pipe: None,
user_options: Default::default(),
},
&BufferLocation::session(BufferId(8), SessionId(1), NodeId(4)),
);
Expand Down Expand Up @@ -2685,6 +2777,7 @@ mod tests {
last_snapshot_seq: 0,
exit_code: None,
pipe: None,
user_options: Default::default(),
},
&BufferLocation::session(BufferId(9), SessionId(1), NodeId(5)),
);
Expand Down Expand Up @@ -2779,6 +2872,84 @@ mod tests {
}
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:?}"),
}

// A value with no --unset parses (the missing-value case is rejected at
// runtime, not at the parser); --unset combined with a value conflicts.
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]
Expand Down
33 changes: 33 additions & 0 deletions crates/embers-cli/tests/panes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,39 @@ async fn wait_for_file_contains(path: &std::path::Path, needle: &str) {
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn buffer_user_options_round_trip_through_cli() {
let _guard = acquire_test_lock().await.expect("acquire test lock");
let server = TestServer::start().await.expect("start server");

run_cli(&server, ["new-session", "alpha"]);
run_cli(&server, ["new-window", "-t", "alpha", "--", "/bin/sh"]);
let split = run_cli(&server, ["split-window", "--", "/bin/sh"]);
let pane_id = stdout(&split)
.trim()
.parse::<u64>()
.expect("split-window returns pane id");
let pane = pane_id.to_string();

run_cli(
&server,
["buffer", "set-option", "-t", &pane, "is-vim", "1"],
);
let shown = run_cli(&server, ["buffer", "show-options", "-t", &pane]);
// JSON-encoded, tab-separated (matches format_buffer_details).
assert_eq!(stdout(&shown).trim(), "\"is-vim\"\t\"1\"");

// Unset removes it.
run_cli(
&server,
["buffer", "set-option", "-t", &pane, "--unset", "is-vim"],
);
let shown = run_cli(&server, ["buffer", "show-options", "-t", &pane]);
assert_eq!(stdout(&shown).trim(), "");

server.shutdown().await.expect("shutdown server");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pane_commands_round_trip_through_cli() {
let _guard = acquire_test_lock().await.expect("acquire test lock");
Expand Down
1 change: 1 addition & 0 deletions crates/embers-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ base64.workspace = true
directories.workspace = true
embers-core.workspace = true
embers-protocol.workspace = true
regex.workspace = true
# `definitions_with_scope` used by scripting/documentation.rs is gated behind `internals`.
rhai = { workspace = true, features = ["internals", "metadata"] }
rhai-autodocs.workspace = true
Expand Down
6 changes: 6 additions & 0 deletions crates/embers-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ where
}
}

/// The client id if it has already been resolved (e.g. after attach or a
/// switch), without issuing a request.
pub fn cached_client_id(&self) -> Option<u64> {
self.client_id.get()
}

pub async fn process_next_event_timeout(
&mut self,
timeout: std::time::Duration,
Expand Down
Loading
Loading