use std::{
fs,
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use super::super::RequestHandler;
use super::session_name;
use crate::outer_terminal::OuterTerminalContext;
use crate::pane_io::AttachControl;
use rmux_core::{input::InputParser, Screen};
use rmux_proto::{
CapturePaneRequest, CopyModeRequest, ListPanesRequest, NewSessionExtRequest, OptionName,
OptionScopeSelector, PaneTarget, Request, Response, ScopeSelector, SendKeysExtRequest,
SetOptionByNameRequest, SetOptionMode, SetOptionRequest, ShowBufferRequest,
SwitchClientRequest, TerminalSize, WindowTarget,
};
use tokio::time::sleep;
fn capture_request(target: PaneTarget, use_mode_screen: bool) -> CapturePaneRequest {
CapturePaneRequest {
target,
start: None,
end: None,
print: true,
buffer_name: None,
alternate: false,
escape_ansi: false,
escape_sequences: false,
include_format: false,
hyperlinks: false,
line_numbers: false,
join_wrapped: false,
use_mode_screen,
preserve_trailing_spaces: false,
do_not_trim_spaces: false,
pending_input: false,
quiet: false,
start_is_absolute: false,
end_is_absolute: false,
}
}
async fn create_session(handler: &RequestHandler, name: &str, size: TerminalSize) -> PaneTarget {
let session_name = session_name(name);
let response = handler
.handle(Request::NewSessionExt(Box::new(NewSessionExtRequest {
session_name: Some(session_name.clone()),
working_directory: None,
detached: true,
size: Some(size),
environment: None,
group_target: None,
attach_if_exists: false,
detach_other_clients: false,
kill_other_clients: false,
flags: None,
window_name: None,
print_session_info: false,
print_format: None,
command: Some(quiet_copy_mode_command()),
process_command: None,
client_environment: None,
skip_environment_update: false,
})))
.await;
assert!(matches!(response, Response::NewSession(_)));
PaneTarget::with_window(session_name, 0, 0)
}
#[cfg(unix)]
fn quiet_copy_mode_command() -> Vec<String> {
vec!["/bin/sh".to_owned(), "-c".to_owned(), "sleep 60".to_owned()]
}
#[cfg(windows)]
fn quiet_copy_mode_command() -> Vec<String> {
let system_root =
std::env::var_os("SystemRoot").unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows"));
let cmd = PathBuf::from(system_root).join("System32").join("cmd.exe");
vec![
cmd.to_string_lossy().into_owned(),
"/d".to_owned(),
"/q".to_owned(),
"/c".to_owned(),
"ping -n 120 127.0.0.1 >NUL".to_owned(),
]
}
async fn replace_transcript_contents(
handler: &RequestHandler,
target: &PaneTarget,
size: TerminalSize,
content: &[u8],
) {
handler
.wait_for_pane_startup_to_finish_for_test(target)
.await;
let transcript = {
let state = handler.state.lock().await;
state
.transcript_handle(target)
.expect("session transcript must exist")
};
let history_limit = transcript
.lock()
.expect("pane transcript mutex must not be poisoned")
.history_limit();
let mut screen = Screen::new(size, history_limit);
let mut parser = InputParser::new();
parser.parse(content, &mut screen);
transcript
.lock()
.expect("pane transcript mutex must not be poisoned")
.set_screen_for_test(screen);
}
async fn wait_for_capture(
handler: &RequestHandler,
target: &PaneTarget,
needle: &str,
use_mode_screen: bool,
) -> String {
for _ in 0..100 {
let response = handler
.handle(Request::CapturePane(Box::new(capture_request(
target.clone(),
use_mode_screen,
))))
.await;
let output = response
.command_output()
.expect("capture-pane returns command output");
let text = String::from_utf8_lossy(output.stdout()).into_owned();
if text.contains(needle) {
return text;
}
sleep(Duration::from_millis(20)).await;
}
panic!("capture output never contained {needle}");
}
async fn enter_copy_mode(handler: &RequestHandler, target: &PaneTarget, page_up: bool) -> Response {
handler
.handle(Request::CopyMode(CopyModeRequest {
target: Some(target.clone()),
page_down: false,
exit_on_scroll: false,
hide_position: false,
mouse_drag_start: false,
cancel_mode: false,
scrollbar_scroll: false,
source: None,
page_up,
}))
.await
}
#[tokio::test]
async fn direct_copy_mode_entry_enables_line_numbers() {
let handler = RequestHandler::new();
let target = create_session(
&handler,
"line-numbers-direct",
TerminalSize { cols: 20, rows: 5 },
)
.await;
assert!(matches!(
enter_copy_mode(&handler, &target, false).await,
Response::CopyMode(_)
));
let transcript = {
let state = handler.state.lock().await;
state
.transcript_handle(&target)
.expect("session transcript must exist")
};
let enabled = transcript
.lock()
.expect("pane transcript mutex must not be poisoned")
.copy_mode_render_snapshot()
.expect("copy-mode snapshot")
.line_numbers_enabled;
assert!(enabled, "ordinary copy-mode entry enables the gutter");
}
async fn pane_terminal_size(handler: &RequestHandler, target: &PaneTarget) -> TerminalSize {
handler
.wait_for_pane_startup_to_finish_for_test(target)
.await;
let master = {
let mut state = handler.state.lock().await;
state
.clone_pane_master_if_alive(
target.session_name(),
target.window_index(),
target.pane_index(),
)
.expect("pane terminal is alive")
};
let size = master.size().expect("pane terminal size is available");
TerminalSize {
cols: size.cols,
rows: size.rows,
}
}
async fn send_copy_mode_command(
handler: &RequestHandler,
target: &PaneTarget,
tokens: &[&str],
) -> Response {
send_copy_mode_command_values(
handler,
target,
tokens.iter().map(|token| (*token).to_owned()).collect(),
)
.await
}
async fn send_copy_mode_command_values(
handler: &RequestHandler,
target: &PaneTarget,
tokens: Vec<String>,
) -> Response {
handler
.handle(Request::SendKeysExt(SendKeysExtRequest {
target: Some(target.clone()),
keys: tokens,
expand_formats: false,
hex: false,
literal: false,
dispatch_key_table: false,
copy_mode_command: true,
forward_mouse_event: false,
reset_terminal: false,
repeat_count: None,
}))
.await
}
async fn send_copy_mode_command_values_as(
handler: &RequestHandler,
requester_pid: u32,
target: &PaneTarget,
tokens: Vec<String>,
) -> Response {
handler
.dispatch(
requester_pid,
Request::SendKeysExt(SendKeysExtRequest {
target: Some(target.clone()),
keys: tokens,
expand_formats: false,
hex: false,
literal: false,
dispatch_key_table: false,
copy_mode_command: true,
forward_mouse_event: false,
reset_terminal: false,
repeat_count: None,
}),
)
.await
.response
}
fn platform_copy_mode_arg(arg: &str) -> String {
match arg {
"cat >/dev/null" => crate::test_shell::stdin_discard_command(),
_ => arg.to_owned(),
}
}
fn unique_copy_pipe_output_path(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock must be after Unix epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"rmux-copy-pipe-{label}-{}-{nanos}.txt",
std::process::id()
))
}
async fn wait_for_file_contents(path: &Path, expected: &str) -> String {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
match fs::read_to_string(path) {
Ok(contents) if contents.contains(expected) => return contents,
Ok(_) | Err(_) => sleep(Duration::from_millis(20)).await,
}
}
})
.await
.unwrap_or_else(|_| {
let observation = fs::read_to_string(path)
.map(|contents| format!("contents were {contents:?}"))
.unwrap_or_else(|error| format!("read failed: {error}"));
panic!(
"file {} never contained {expected:?} within 5 seconds; {observation}",
path.display()
);
})
}
#[cfg(unix)]
fn stdin_to_file_command(path: &Path) -> String {
format!("cat > {}", crate::test_shell::sh_quote_path(path))
}
#[cfg(unix)]
fn stdin_to_relative_file_command(name: &str) -> String {
format!("cat > {}", crate::test_shell::sh_quote(name))
}
#[cfg(windows)]
fn stdin_to_file_command(path: &Path) -> String {
let quoted_path = crate::test_shell::powershell_quote_path(path);
crate::test_shell::powershell_encoded_command(&format!(
"$inputStream=[Console]::OpenStandardInput(); \
$output=[System.IO.File]::Create({quoted_path}); \
try {{ $inputStream.CopyTo($output) }} finally {{ $output.Dispose() }}"
))
}
#[cfg(unix)]
fn file_url_path(path: &Path) -> String {
path.to_string_lossy().replace(' ', "%20")
}
async fn set_copy_command(handler: &RequestHandler, command: String) {
let response = handler
.handle(Request::SetOptionByName(Box::new(SetOptionByNameRequest {
scope: OptionScopeSelector::ServerGlobal,
name: "copy-command".to_owned(),
value: Some(command),
mode: SetOptionMode::Replace,
only_if_unset: false,
unset: false,
unset_pane_overrides: false,
format: false,
format_target: None,
})))
.await;
assert!(
matches!(response, Response::SetOptionByName(_)),
"set-option copy-command returned {response:?}"
);
}
#[cfg(unix)]
async fn set_default_shell(handler: &RequestHandler, shell: &Path) {
let response = handler
.handle(Request::SetOptionByName(Box::new(SetOptionByNameRequest {
scope: OptionScopeSelector::ServerGlobal,
name: "default-shell".to_owned(),
value: Some(shell.to_string_lossy().into_owned()),
mode: SetOptionMode::Replace,
only_if_unset: false,
unset: false,
unset_pane_overrides: false,
format: false,
format_target: None,
})))
.await;
assert!(
matches!(response, Response::SetOptionByName(_)),
"set-option default-shell returned {response:?}"
);
}
async fn set_set_clipboard(handler: &RequestHandler, value: &str) {
let response = handler
.handle(Request::SetOptionByName(Box::new(SetOptionByNameRequest {
scope: OptionScopeSelector::ServerGlobal,
name: "set-clipboard".to_owned(),
value: Some(value.to_owned()),
mode: SetOptionMode::Replace,
only_if_unset: false,
unset: false,
unset_pane_overrides: false,
format: false,
format_target: None,
})))
.await;
assert!(
matches!(response, Response::SetOptionByName(_)),
"set-option set-clipboard returned {response:?}"
);
}
fn take_write(control: AttachControl) -> Option<Vec<u8>> {
match control {
AttachControl::Write(bytes) => Some(bytes),
_ => None,
}
}
async fn prepare_transfer_selection(handler: &RequestHandler, target: &PaneTarget) {
let response = send_copy_mode_command(handler, target, &["select-line"]).await;
assert!(matches!(response, Response::SendKeys(_)));
}
#[tokio::test]
async fn copy_mode_capture_uses_backing_screen_snapshot() {
let handler = RequestHandler::new();
let size = TerminalSize { cols: 24, rows: 3 };
let target = create_session(&handler, "alpha", size).await;
replace_transcript_contents(
&handler,
&target,
size,
b"line1\r\nline2\r\nline3\r\nline4\r\nline5\r\n",
)
.await;
let response = enter_copy_mode(&handler, &target, true).await;
assert_eq!(
response,
Response::CopyMode(rmux_proto::CopyModeResponse {
target: target.clone(),
active: true,
view_mode: false,
})
);
let mode_capture = wait_for_capture(&handler, &target, "line2", true).await;
assert_eq!(mode_capture, "line1\nline2\nline3\n");
}
#[tokio::test]
async fn modal_scrollbar_resizes_pty_only_while_copy_mode_is_active() {
let handler = RequestHandler::new();
let size = TerminalSize { cols: 20, rows: 8 };
let target = create_session(&handler, "scrollbar-modal", size).await;
assert_eq!(pane_terminal_size(&handler, &target).await, size);
let response = handler
.handle(Request::SetOption(SetOptionRequest {
scope: ScopeSelector::Window(WindowTarget::with_window(
target.session_name().clone(),
target.window_index(),
)),
option: OptionName::PaneScrollbars,
value: "modal".to_owned(),
mode: SetOptionMode::Replace,
}))
.await;
assert!(matches!(response, Response::SetOption(_)));
assert_eq!(pane_terminal_size(&handler, &target).await, size);
assert!(matches!(
enter_copy_mode(&handler, &target, false).await,
Response::CopyMode(_)
));
assert_eq!(
pane_terminal_size(&handler, &target).await,
TerminalSize { cols: 19, rows: 8 }
);
let response = handler
.handle(Request::CopyMode(CopyModeRequest {
target: Some(target.clone()),
page_down: false,
exit_on_scroll: false,
hide_position: false,
mouse_drag_start: false,
cancel_mode: true,
scrollbar_scroll: false,
source: None,
page_up: false,
}))
.await;
assert!(matches!(response, Response::CopyMode(_)));
assert_eq!(pane_terminal_size(&handler, &target).await, size);
for (value, expected_cols) in [("on", 19), ("off", 20)] {
let response = handler
.handle(Request::SetOption(SetOptionRequest {
scope: ScopeSelector::Window(WindowTarget::with_window(
target.session_name().clone(),
target.window_index(),
)),
option: OptionName::PaneScrollbars,
value: value.to_owned(),
mode: SetOptionMode::Replace,
}))
.await;
assert!(matches!(response, Response::SetOption(_)));
assert_eq!(
pane_terminal_size(&handler, &target).await,
TerminalSize {
cols: expected_cols,
rows: 8,
}
);
}
}
#[tokio::test]
async fn copy_mode_line_number_gutter_never_resizes_the_pty() {
let handler = RequestHandler::new();
let size = TerminalSize { cols: 20, rows: 8 };
let target = create_session(&handler, "line-number-pty", size).await;
assert_eq!(pane_terminal_size(&handler, &target).await, size);
let response = handler
.handle(Request::SetOption(SetOptionRequest {
scope: ScopeSelector::Window(WindowTarget::with_window(
target.session_name().clone(),
target.window_index(),
)),
option: OptionName::CopyModeLineNumbers,
value: "absolute".to_owned(),
mode: SetOptionMode::Replace,
}))
.await;
assert!(matches!(response, Response::SetOption(_)));
assert!(matches!(
enter_copy_mode(&handler, &target, false).await,
Response::CopyMode(_)
));
assert_eq!(
pane_terminal_size(&handler, &target).await,
size,
"the line-number gutter is an internal copy-mode overlay"
);
}
#[tokio::test]
async fn copy_mode_formats_report_live_state() {
let handler = RequestHandler::new();
let size = TerminalSize { cols: 40, rows: 4 };
let target = create_session(&handler, "beta", size).await;
replace_transcript_contents(
&handler,
&target,
size,
b"alpha beta gamma\r\nneedle here\r\nomega\r\n",
)
.await;
assert!(matches!(
handler
.handle(Request::SetOption(SetOptionRequest {
scope: ScopeSelector::Global,
option: OptionName::CopyModeLineNumbers,
value: "absolute".to_owned(),
mode: SetOptionMode::Replace,
}))
.await,
Response::SetOption(_)
));
assert!(matches!(
enter_copy_mode(&handler, &target, false).await,
Response::CopyMode(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["search-backward", "--", "needle"]).await,
Response::SendKeys(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["select-word"]).await,
Response::SendKeys(_)
));
let listed = handler
.handle(Request::ListPanes(Box::new(ListPanesRequest {
target: target.session_name().clone(),
format: Some(
"#{pane_in_mode} #{pane_mode} #{search_present} #{selection_present} #{copy_cursor_word} #{copy_position}/#{copy_position_limit}".to_owned(),
),
filter: None,
sort_order: None,
reversed: false,
target_window_index: None,
})))
.await;
let output = listed
.command_output()
.expect("list-panes returns command output");
let text = String::from_utf8_lossy(output.stdout());
assert_eq!(text.as_ref(), "1 copy-mode 1 1 needle 1/4\n");
}
#[tokio::test]
async fn copy_mode_command_table_dispatches_all_tmux_commands() {
const COMMANDS: &[(&str, &[&str])] = &[
("append-selection", &[]),
("append-selection-and-cancel", &[]),
("back-to-indentation", &[]),
("begin-selection", &[]),
("bottom-line", &[]),
("cancel", &[]),
("clear-selection", &[]),
("copy-end-of-line", &[]),
("copy-end-of-line-and-cancel", &[]),
("copy-pipe-end-of-line", &["cat >/dev/null"]),
("copy-pipe-end-of-line-and-cancel", &["cat >/dev/null"]),
("copy-line", &[]),
("copy-line-and-cancel", &[]),
("copy-pipe-line", &["cat >/dev/null"]),
("copy-pipe-line-and-cancel", &["cat >/dev/null"]),
("copy-pipe-no-clear", &["cat >/dev/null"]),
("copy-pipe", &["cat >/dev/null"]),
("copy-pipe-and-cancel", &["cat >/dev/null"]),
("copy-selection-no-clear", &[]),
("copy-selection", &[]),
("copy-selection-and-cancel", &[]),
("cursor-down", &[]),
("cursor-down-and-cancel", &[]),
("cursor-left", &[]),
("cursor-right", &[]),
("cursor-up", &[]),
("cursor-centre-vertical", &[]),
("cursor-centre-horizontal", &[]),
("end-of-buffer", &[]),
("end-of-line", &[]),
("goto-line", &["1"]),
("halfpage-down", &[]),
("halfpage-down-and-cancel", &[]),
("halfpage-up", &[]),
("history-bottom", &[]),
("history-top", &[]),
("jump-again", &[]),
("jump-backward", &["a"]),
("jump-forward", &["a"]),
("jump-reverse", &[]),
("jump-to-backward", &["a"]),
("jump-to-forward", &["a"]),
("jump-to-mark", &[]),
("next-prompt", &[]),
("previous-prompt", &[]),
("middle-line", &[]),
("next-matching-bracket", &[]),
("next-paragraph", &[]),
("next-space", &[]),
("next-space-end", &[]),
("next-word", &[]),
("next-word-end", &[]),
("other-end", &[]),
("page-down", &[]),
("page-down-and-cancel", &[]),
("page-up", &[]),
("pipe-no-clear", &["cat >/dev/null"]),
("pipe", &["cat >/dev/null"]),
("pipe-and-cancel", &["cat >/dev/null"]),
("previous-matching-bracket", &[]),
("previous-paragraph", &[]),
("previous-space", &[]),
("previous-word", &[]),
("rectangle-on", &[]),
("rectangle-off", &[]),
("rectangle-toggle", &[]),
("refresh-from-pane", &[]),
("recentre-top-bottom", &[]),
("scroll-bottom", &[]),
("scroll-down", &[]),
("scroll-down-and-cancel", &[]),
("scroll-exit-on", &[]),
("scroll-exit-off", &[]),
("scroll-exit-toggle", &[]),
("scroll-middle", &[]),
("scroll-to-mouse", &[]),
("scroll-top", &[]),
("scroll-up", &[]),
("search-again", &[]),
("search-backward", &["alpha"]),
("search-backward-text", &["alpha"]),
("search-backward-incremental", &["-:alpha"]),
("search-forward", &["alpha"]),
("search-forward-text", &["alpha"]),
("search-forward-incremental", &["+:alpha"]),
("search-reverse", &[]),
("select-line", &[]),
("select-word", &[]),
("selection-mode", &["word"]),
("set-mark", &[]),
("start-of-buffer", &[]),
("start-of-line", &[]),
("stop-selection", &[]),
("toggle-position", &[]),
("top-line", &[]),
];
let handler = RequestHandler::new();
let target = create_session(&handler, "gamma", TerminalSize { cols: 48, rows: 6 }).await;
replace_transcript_contents(
&handler,
&target,
TerminalSize { cols: 48, rows: 6 },
b"(alpha) beta gamma\r\nword_two more words\r\nthird paragraph\r\n\r\nfourth line\r\nlast line\r\n",
)
.await;
wait_for_capture(&handler, &target, "last line", false).await;
for (command, args) in COMMANDS {
assert!(matches!(
enter_copy_mode(&handler, &target, false).await,
Response::CopyMode(_)
));
match *command {
"append-selection"
| "append-selection-and-cancel"
| "copy-pipe-no-clear"
| "copy-pipe"
| "copy-pipe-and-cancel"
| "copy-selection-no-clear"
| "copy-selection"
| "copy-selection-and-cancel"
| "pipe-no-clear"
| "pipe"
| "pipe-and-cancel"
| "stop-selection" => prepare_transfer_selection(&handler, &target).await,
"other-end" => {
prepare_transfer_selection(&handler, &target).await;
let _ = send_copy_mode_command(&handler, &target, &["cursor-right"]).await;
}
"jump-again" | "jump-reverse" => {
let _ =
send_copy_mode_command(&handler, &target, &["jump-forward", "--", "a"]).await;
}
"jump-to-mark" => {
let _ = send_copy_mode_command(&handler, &target, &["set-mark"]).await;
let _ = send_copy_mode_command(&handler, &target, &["cursor-down"]).await;
}
"search-again" | "search-reverse" => {
let _ =
send_copy_mode_command(&handler, &target, &["search-backward", "--", "alpha"])
.await;
}
_ => {}
}
let mut tokens = vec![(*command).to_owned()];
if !args.is_empty() {
tokens.push("--".to_owned());
tokens.extend(args.iter().map(|arg| platform_copy_mode_arg(arg)));
}
let response = send_copy_mode_command_values(&handler, &target, tokens).await;
assert!(
!matches!(response, Response::Error(_)),
"{command} returned {response:?}"
);
}
}
#[tokio::test]
async fn copy_mode_copy_selection_and_cancel_writes_buffer() {
let handler = RequestHandler::new();
let target = create_session(&handler, "delta", TerminalSize { cols: 40, rows: 4 }).await;
replace_transcript_contents(
&handler,
&target,
TerminalSize { cols: 40, rows: 4 },
b"alpha\r\nneedle value\r\nomega\r\n",
)
.await;
wait_for_capture(&handler, &target, "needle", false).await;
assert!(matches!(
enter_copy_mode(&handler, &target, false).await,
Response::CopyMode(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["search-backward", "--", "needle"]).await,
Response::SendKeys(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["select-word"]).await,
Response::SendKeys(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["copy-selection-and-cancel"]).await,
Response::SendKeys(_)
));
let buffer = handler
.handle(Request::ShowBuffer(ShowBufferRequest { name: None }))
.await;
let output = buffer.command_output().expect("show-buffer returns output");
assert!(String::from_utf8_lossy(output.stdout()).contains("needle"));
}
#[tokio::test]
async fn attached_copy_mode_switch_after_mutation_is_not_a_fatal_input_error() {
let handler = std::sync::Arc::new(RequestHandler::new());
let requester_pid = 71_404;
let alpha = create_session(
&handler,
"copy-switch-alpha",
TerminalSize { cols: 40, rows: 4 },
)
.await;
let beta = create_session(
&handler,
"copy-switch-beta",
TerminalSize { cols: 40, rows: 4 },
)
.await;
let (control_tx, _control_rx) = tokio::sync::mpsc::unbounded_channel();
handler
.register_attach(requester_pid, alpha.session_name().clone(), control_tx)
.await;
assert!(matches!(
enter_copy_mode(&handler, &alpha, false).await,
Response::CopyMode(_)
));
let pause = super::super::copy_mode_support::install_copy_mode_mutation_pause(requester_pid);
let input_handler = std::sync::Arc::clone(&handler);
let input = tokio::spawn(async move {
input_handler
.handle_attached_live_input_for_test(requester_pid, b"q")
.await
});
pause.reached.notified().await;
let switched = handler
.dispatch(
requester_pid,
Request::SwitchClient(SwitchClientRequest {
target: beta.session_name().clone(),
}),
)
.await
.response;
assert!(
matches!(switched, Response::SwitchClient(_)),
"{switched:?}"
);
pause.release.notify_one();
let result = input.await.expect("copy-mode input task joins");
assert!(
result.is_ok(),
"post-mutation switch must not fail input: {result:?}"
);
let active_session = {
let active_attach = handler.active_attach.lock().await;
active_attach.current_session_candidate(requester_pid)
};
assert_eq!(active_session.as_ref(), Some(beta.session_name()));
}
#[tokio::test]
async fn copy_pipe_without_command_uses_copy_command_option() {
let handler = RequestHandler::new();
let target = create_session(&handler, "copy-command", TerminalSize { cols: 40, rows: 4 }).await;
let output_path = unique_copy_pipe_output_path("fallback");
set_copy_command(&handler, stdin_to_file_command(&output_path)).await;
replace_transcript_contents(
&handler,
&target,
TerminalSize { cols: 40, rows: 4 },
b"alpha\r\nneedle fallback\r\nomega\r\n",
)
.await;
wait_for_capture(&handler, &target, "needle fallback", false).await;
assert!(matches!(
enter_copy_mode(&handler, &target, false).await,
Response::CopyMode(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["search-backward", "--", "needle"]).await,
Response::SendKeys(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["select-line"]).await,
Response::SendKeys(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["copy-pipe-and-cancel"]).await,
Response::SendKeys(_)
));
let output = wait_for_file_contents(&output_path, "needle fallback").await;
let _ = fs::remove_file(&output_path);
assert!(output.contains("needle fallback"));
}
#[tokio::test]
async fn copy_pipe_hands_the_copy_command_non_ascii_selections_byte_exactly() {
const NEEDLE: &str = "needle â•─│╯ café 日本";
let handler = RequestHandler::new();
let size = TerminalSize { cols: 60, rows: 4 };
let target = create_session(&handler, "copy-command-utf8", size).await;
let output_path = unique_copy_pipe_output_path("utf8");
set_copy_command(&handler, stdin_to_file_command(&output_path)).await;
replace_transcript_contents(
&handler,
&target,
size,
format!("alpha\r\n{NEEDLE}\r\nomega\r\n").as_bytes(),
)
.await;
wait_for_capture(&handler, &target, NEEDLE, false).await;
assert!(matches!(
enter_copy_mode(&handler, &target, false).await,
Response::CopyMode(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["search-backward", "--", "needle"]).await,
Response::SendKeys(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["select-line"]).await,
Response::SendKeys(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["copy-pipe-and-cancel"]).await,
Response::SendKeys(_)
));
wait_for_file_contents(&output_path, NEEDLE).await;
let piped = fs::read(&output_path).expect("copy-pipe output is readable");
let _ = fs::remove_file(&output_path);
assert!(
piped
.windows(NEEDLE.len())
.any(|window| window == NEEDLE.as_bytes()),
"copy-pipe delivered {piped:?}, which does not contain the selection's UTF-8 bytes"
);
assert!(
String::from_utf8(piped).is_ok(),
"copy-pipe delivered bytes that are not valid UTF-8"
);
}
#[tokio::test]
async fn copy_pipe_explicit_command_overrides_copy_command_option() {
let handler = RequestHandler::new();
let target = create_session(
&handler,
"copy-command-explicit",
TerminalSize { cols: 40, rows: 4 },
)
.await;
let fallback_path = unique_copy_pipe_output_path("fallback-unused");
let explicit_path = unique_copy_pipe_output_path("explicit");
set_copy_command(&handler, stdin_to_file_command(&fallback_path)).await;
replace_transcript_contents(
&handler,
&target,
TerminalSize { cols: 40, rows: 4 },
b"alpha\r\nneedle explicit\r\nomega\r\n",
)
.await;
wait_for_capture(&handler, &target, "needle explicit", false).await;
assert!(matches!(
enter_copy_mode(&handler, &target, false).await,
Response::CopyMode(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["search-backward", "--", "needle"]).await,
Response::SendKeys(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["select-line"]).await,
Response::SendKeys(_)
));
let explicit_command = stdin_to_file_command(&explicit_path);
assert!(matches!(
send_copy_mode_command_values(
&handler,
&target,
vec![
"copy-pipe-and-cancel".to_owned(),
"--".to_owned(),
explicit_command,
],
)
.await,
Response::SendKeys(_)
));
let explicit_output = wait_for_file_contents(&explicit_path, "needle explicit").await;
let fallback_output = fs::read_to_string(&fallback_path).ok();
let _ = fs::remove_file(&explicit_path);
let _ = fs::remove_file(&fallback_path);
assert!(explicit_output.contains("needle explicit"));
assert!(
fallback_output.is_none(),
"copy-command fallback should not run when copy-pipe has an explicit command"
);
}
#[tokio::test]
async fn copy_mode_buffer_yank_emits_clipboard_when_set_clipboard_enabled() {
let handler = RequestHandler::new();
let target = create_session(
&handler,
"copy-mode-clipboard",
TerminalSize { cols: 40, rows: 4 },
)
.await;
let requester_pid = 42;
set_set_clipboard(&handler, "external").await;
let (control_tx, mut control_rx) = tokio::sync::mpsc::unbounded_channel();
handler
.register_attach_with_terminal_context(
requester_pid,
target.session_name().clone(),
control_tx,
OuterTerminalContext::from_pairs(&[("TERM", "xterm-256color")]),
)
.await;
replace_transcript_contents(
&handler,
&target,
TerminalSize { cols: 40, rows: 4 },
b"alpha\r\nneedle clipboard\r\nomega\r\n",
)
.await;
wait_for_capture(&handler, &target, "needle clipboard", false).await;
assert!(matches!(
enter_copy_mode(&handler, &target, false).await,
Response::CopyMode(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["search-backward", "--", "needle"]).await,
Response::SendKeys(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["select-line"]).await,
Response::SendKeys(_)
));
assert!(matches!(
send_copy_mode_command_values_as(
&handler,
requester_pid,
&target,
vec!["copy-selection".to_owned()],
)
.await,
Response::SendKeys(_)
));
let mut bytes = None;
while let Ok(control) = control_rx.try_recv() {
bytes = take_write(control).or(bytes);
if bytes.is_some() {
break;
}
}
let bytes = bytes.expect("clipboard write");
assert_eq!(bytes, b"\x1b]52;;bmVlZGxlIGNsaXBib2FyZA==\x07");
}
#[cfg(unix)]
fn write_executable_script(path: &Path, contents: &str) {
fs::write(path, contents).expect("write script");
let mut permissions = fs::metadata(path).expect("script metadata").permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).expect("script permissions");
}
#[cfg(unix)]
#[tokio::test]
async fn copy_pipe_uses_local_osc7_file_url_as_working_directory() {
let handler = RequestHandler::new();
let target = create_session(
&handler,
"copy-pipe-osc7-cwd",
TerminalSize { cols: 40, rows: 4 },
)
.await;
let temp_dir = std::env::temp_dir().join(format!("rmux copy pipe cwd {}", std::process::id()));
fs::create_dir_all(&temp_dir).expect("temp dir exists");
let output_path = temp_dir.join("copied.txt");
replace_transcript_contents(
&handler,
&target,
TerminalSize { cols: 40, rows: 4 },
b"alpha\r\nneedle osc7 cwd\r\nomega\r\n",
)
.await;
{
let mut state = handler.state.lock().await;
let osc7 = format!("\x1b]7;file://localhost{}\x07", file_url_path(&temp_dir));
state
.append_bytes_to_pane_transcript_for_test(
target.session_name(),
target.window_index(),
target.pane_index(),
osc7.as_bytes(),
)
.expect("OSC7 bytes append to pane transcript");
}
wait_for_capture(&handler, &target, "needle osc7 cwd", false).await;
assert!(matches!(
enter_copy_mode(&handler, &target, false).await,
Response::CopyMode(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["search-backward", "--", "needle"]).await,
Response::SendKeys(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["select-line"]).await,
Response::SendKeys(_)
));
assert!(matches!(
send_copy_mode_command_values(
&handler,
&target,
vec![
"copy-pipe-and-cancel".to_owned(),
"--".to_owned(),
stdin_to_relative_file_command("copied.txt"),
],
)
.await,
Response::SendKeys(_)
));
let output = wait_for_file_contents(&output_path, "needle osc7 cwd").await;
let _ = fs::remove_file(&output_path);
let _ = fs::remove_dir(&temp_dir);
assert!(output.contains("needle osc7 cwd"));
}
#[cfg(unix)]
#[tokio::test]
async fn copy_pipe_uses_bin_sh_instead_of_default_shell_like_tmux() {
let handler = RequestHandler::new();
let root = std::env::temp_dir().join(format!(
"rmux-copy-pipe-bin-sh-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock must be after Unix epoch")
.as_nanos()
));
fs::create_dir_all(&root).expect("temp dir exists");
let fake_shell = root.join("fake-shell.sh");
let marker_path = root.join("default-shell-used.txt");
let output_path = root.join("copy-pipe-output.txt");
write_executable_script(
&fake_shell,
&format!(
"#!/bin/sh\nprintf used > {}\nexit 42\n",
crate::test_shell::sh_quote_path(&marker_path)
),
);
set_default_shell(&handler, &fake_shell).await;
let target = create_session(
&handler,
"copy-pipe-bin-sh",
TerminalSize { cols: 40, rows: 4 },
)
.await;
replace_transcript_contents(
&handler,
&target,
TerminalSize { cols: 40, rows: 4 },
b"alpha\r\nneedle bin sh\r\nomega\r\n",
)
.await;
wait_for_capture(&handler, &target, "needle bin sh", false).await;
assert!(matches!(
enter_copy_mode(&handler, &target, false).await,
Response::CopyMode(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["search-backward", "--", "needle"]).await,
Response::SendKeys(_)
));
assert!(matches!(
send_copy_mode_command(&handler, &target, &["select-line"]).await,
Response::SendKeys(_)
));
let explicit_command = stdin_to_file_command(&output_path);
assert!(matches!(
send_copy_mode_command_values(
&handler,
&target,
vec![
"copy-pipe-and-cancel".to_owned(),
"--".to_owned(),
explicit_command,
],
)
.await,
Response::SendKeys(_)
));
let output = wait_for_file_contents(&output_path, "needle bin sh").await;
assert!(output.contains("needle bin sh"));
assert!(
!marker_path.exists(),
"copy-pipe should not execute default-shell for tmux jobs"
);
let _ = fs::remove_file(&output_path);
let _ = fs::remove_file(&fake_shell);
let _ = fs::remove_dir_all(&root);
}