use crate::app::tmux_handler::tmux_state::TmuxState;
use crate::app::window_state::WindowState;
use crate::pane::PaneId;
use crate::tab::{Tab, TabId};
#[cfg_attr(not(feature = "mux"), allow(unused_variables))]
pub(crate) fn route_mux_pane_write_state(
tmux_state: &TmuxState,
tab_id: TabId,
pane_id: PaneId,
data: &[u8],
) -> bool {
#[cfg(feature = "mux")]
if let Some(transport) = &tmux_state.transport
&& let Some(pane) = tmux_state.tmux_pane_in_tab(tab_id, pane_id)
{
super::notifications::mux::route_literal_bytes(&**transport, Some(pane), data);
return true;
}
false
}
impl WindowState {
#[cfg_attr(not(feature = "mux"), allow(unused_variables))]
pub(crate) fn route_mux_pane_write(&self, tab_id: TabId, pane_id: PaneId, data: &[u8]) -> bool {
route_mux_pane_write_state(&self.tmux_state, tab_id, pane_id, data)
}
pub(crate) fn route_mux_tab_write(&self, tab: &Tab, data: &[u8]) -> bool {
let Some(pane) = tab.pane_manager().and_then(|pm| pm.focused_pane()) else {
return false;
};
self.route_mux_pane_write(tab.id, pane.id, data)
}
}
#[cfg(all(test, feature = "mux"))]
pub(crate) mod tests {
use super::*;
use crate::app::tmux_handler::notifications::mux::{MuxAttachPending, tests as mux_tests};
use crate::app::tmux_handler::notifications::mux_test_seams::{SettledSend, wait_until};
use crate::app::tmux_handler::tmux_state::TmuxTransport;
use crate::app::window_state::WindowState;
use crate::config::Config;
use std::sync::{Arc, Mutex};
pub(crate) struct RecordingTransport {
sent: Arc<Mutex<Vec<String>>>,
}
impl RecordingTransport {
pub(crate) fn new() -> (Self, Arc<Mutex<Vec<String>>>) {
let sent = Arc::new(Mutex::new(Vec::new()));
(
Self {
sent: Arc::clone(&sent),
},
sent,
)
}
}
impl TmuxTransport for RecordingTransport {
fn drain(
&self,
) -> (
Vec<par_term_emu_core_rust::tmux_control::TmuxNotification>,
bool,
) {
(Vec::new(), false)
}
fn send_command(&self, command: &str) -> std::io::Result<Vec<String>> {
self.sent.lock().unwrap().push(command.to_string());
Ok(Vec::new())
}
}
fn test_runtime() -> Arc<tokio::runtime::Runtime> {
Arc::new(
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build test runtime"),
)
}
fn hex_of(text: &str) -> String {
text.bytes()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) fn mux_state_with_recorder(
tag: &str,
config: Config,
) -> (WindowState, Arc<Mutex<Vec<String>>>, TabId, PaneId) {
let path = mux_tests::socket_path(tag);
mux_tests::spawn_daemon(&path);
let core_client = par_term_emu_core_rust::mux::MuxClient::connect(&path).expect("connect");
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Ok(core_client)).unwrap();
drop(tx);
let mut ws = WindowState::new(config, test_runtime());
ws.tmux_state.mux_attach_pending = Some(MuxAttachPending {
name: "writetest".to_string(),
rx,
});
ws.poll_mux_attach();
assert!(ws.tmux_state.transport.is_some(), "attach must install");
ws.handle_tmux_window_add(0);
wait_until("window @0 gets a mapped pane", || {
if let Some(t) = ws.tmux_state.transport.as_ref() {
let _ = t.send_command_no_wait("refresh-client -t %0 -C 80x24");
}
ws.check_mux_notifications();
ws.tmux_state.tmux_pane_owners.contains_key(&0)
});
let (tab_id, pane) = ws.tmux_state.tmux_pane_owners[&0];
ws.tab_manager.switch_to(tab_id);
let (transport, sent) = RecordingTransport::new();
ws.tmux_state.transport = Some(Box::new(transport));
(ws, sent, tab_id, pane)
}
#[test]
fn tab_write_routes_focused_mirror_pane() {
let (ws, sent, tab_id, pane) =
mux_state_with_recorder("pane-write-helper", Config::default());
let tab = ws.tab_manager.get_tab(tab_id).expect("mux tab");
assert!(
ws.route_mux_tab_write(tab, b"helper-needle"),
"the focused mirror pane must route to the daemon"
);
assert_eq!(
*sent.lock().unwrap(),
vec![format!("send-keys -t %0 -H {}", hex_of("helper-needle"))],
"the bytes must go to the daemon as one literal send-keys"
);
let _ = pane;
}
#[test]
fn ctrl_l_clear_routes_to_the_daemon_pane() {
let (ws, sent, tab_id, pane) =
mux_state_with_recorder("pane-write-ctrl-l", Config::default());
let _ = (tab_id, pane);
ws.send_clear_screen_sequence();
assert_eq!(
*sent.lock().unwrap(),
vec!["send-keys -t %0 -H 0c".to_string()],
"Ctrl+L must clear the daemon pane, not the hidden shell"
);
}
#[test]
fn snippet_write_routes_to_the_daemon_pane() {
let mut config = Config::default();
config.snippets.push(
serde_yaml_ng::from_str::<crate::config::snippets::SnippetConfig>(
"id: snip\ntitle: Snip\ncontent: mux-write-needle",
)
.expect("snippet config"),
);
let (mut ws, sent, tab_id, pane) = mux_state_with_recorder("pane-write-snippet", config);
let _ = (tab_id, pane);
assert!(ws.execute_snippet("snip"), "snippet must execute");
assert_eq!(
*sent.lock().unwrap(),
vec![format!("send-keys -t %0 -H {}", hex_of("mux-write-needle"))],
"the snippet must reach the daemon pane, not the hidden shell"
);
}
#[test]
fn insert_text_action_routes_to_the_daemon_pane() {
use crate::config::snippets::CustomActionConfig;
let mut config = Config::default();
config.actions.push(CustomActionConfig::InsertText {
id: "act".to_string(),
title: "Act".to_string(),
text: "insert-needle".to_string(),
variables: Default::default(),
keybinding: None,
prefix_char: None,
keybinding_enabled: true,
description: None,
});
let (mut ws, sent, tab_id, pane) = mux_state_with_recorder("pane-write-insert", config);
let _ = (tab_id, pane);
assert!(ws.execute_custom_action("act"), "action must execute");
assert_eq!(
*sent.lock().unwrap(),
vec![format!("send-keys -t %0 -H {}", hex_of("insert-needle"))],
"InsertText must reach the daemon pane, not the hidden shell"
);
}
#[test]
fn broadcast_reaches_every_daemon_pane_in_a_split_mux_tab() {
let path = mux_tests::socket_path("pane-write-broadcast");
mux_tests::spawn_daemon(&path);
let core_client = par_term_emu_core_rust::mux::MuxClient::connect(&path).expect("connect");
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Ok(core_client)).unwrap();
drop(tx);
let mut ws = WindowState::new(Config::default(), test_runtime());
ws.tmux_state.mux_attach_pending = Some(MuxAttachPending {
name: "broadcasttest".to_string(),
rx,
});
ws.poll_mux_attach();
assert!(ws.tmux_state.transport.is_some(), "attach must install");
ws.handle_tmux_window_add(0);
wait_until("window @0 gets a mapped pane", || {
if let Some(t) = ws.tmux_state.transport.as_ref() {
let _ = t.send_command_no_wait("refresh-client -t %0 -C 80x24");
}
ws.check_mux_notifications();
ws.tmux_state.tmux_pane_owners.contains_key(&0)
});
ws.tmux_state
.transport
.as_ref()
.expect("transport")
.send_settled("split-window -h -t %0")
.expect("daemon-side split");
let tab_id = ws.tmux_state.tmux_pane_owners[&0].0;
wait_until("the daemon split produces a second mirror pane", || {
ws.check_mux_notifications();
ws.tmux_state.tab_tmux_pane_ids(tab_id).len() >= 2
});
let (transport, sent) = RecordingTransport::new();
ws.tmux_state.transport = Some(Box::new(transport));
ws.tab_manager.switch_to(tab_id);
let drain = |sent: &Arc<Mutex<Vec<String>>>| {
let mut routed: Vec<String> = std::mem::take(&mut *sent.lock().unwrap());
routed.sort();
routed
};
assert!(
!ws.broadcast_bytes(b"z"),
"broadcast is off until toggled for this tab"
);
ws.toggle_broadcast_input();
assert!(
ws.tab_manager.active_tab().unwrap().broadcast_input,
"the flag lives on the tab (UX.md D8)"
);
assert!(
ws.broadcast_bytes(b"z"),
"a split mux tab with broadcast on must consume the bytes"
);
assert_eq!(
drain(&sent),
vec![
"send-keys -t %0 -H 7a".to_string(),
"send-keys -t %1 -H 7a".to_string(),
],
"broadcast must reach both daemon panes"
);
assert!(ws.broadcast_paste("hi"), "broadcast consumes the paste");
let sent_paste = drain(&sent);
assert_eq!(sent_paste.len(), 2, "one burst per daemon pane");
for (i, cmd) in sent_paste.iter().enumerate() {
let hex = cmd
.strip_prefix(&format!("send-keys -t %{} -H ", i))
.unwrap_or_else(|| panic!("unexpected paste command: {cmd}"));
let bytes = hex
.split_ascii_whitespace()
.map(|pair| u8::from_str_radix(pair, 16))
.collect::<Result<Vec<u8>, _>>()
.unwrap_or_else(|e| panic!("bad hex in {cmd}: {e}"));
const WRAP_START: [u8; 6] = [0x1b, 0x5b, 0x32, 0x30, 0x30, 0x7e];
const WRAP_END: [u8; 6] = [0x1b, 0x5b, 0x32, 0x30, 0x31, 0x7e];
let core = bytes
.strip_prefix(&WRAP_START)
.unwrap_or(&bytes)
.strip_suffix(&WRAP_END)
.unwrap_or_else(|| bytes.strip_prefix(&WRAP_START).unwrap_or(&bytes));
assert_eq!(
core, b"hi",
"pane %{i} paste payload must be exactly hi (wrapper allowed): {cmd}"
);
}
let excluded_native = ws.tmux_state.tmux_pane_owners[&1].1;
ws.tab_manager
.active_tab_mut()
.and_then(|t| t.pane_manager_mut())
.and_then(|pm| pm.get_pane_mut(excluded_native))
.expect("pane %1")
.broadcast_excluded = true;
assert!(ws.broadcast_bytes(b"z"));
assert_eq!(drain(&sent), vec!["send-keys -t %0 -H 7a".to_string()]);
}
#[test]
fn local_tab_writes_do_not_route() {
let mut ws = WindowState::new(Config::default(), test_runtime());
ws.tab_manager
.new_tab(&Config::default(), test_runtime(), false, None)
.expect("local tab");
let tab = ws.tab_manager.active_tab().expect("tab");
assert!(
!ws.route_mux_tab_write(tab, b"hello"),
"no transport attached: the caller must keep its local write"
);
}
pub(crate) struct SplitReplyingTransport {
sent: Arc<Mutex<Vec<String>>>,
new_pane: u64,
}
impl SplitReplyingTransport {
pub(crate) fn new(new_pane: u64) -> (Self, Arc<Mutex<Vec<String>>>) {
let sent = Arc::new(Mutex::new(Vec::new()));
(
Self {
sent: Arc::clone(&sent),
new_pane,
},
sent,
)
}
}
impl TmuxTransport for SplitReplyingTransport {
fn drain(
&self,
) -> (
Vec<par_term_emu_core_rust::tmux_control::TmuxNotification>,
bool,
) {
(Vec::new(), false)
}
fn send_command(&self, command: &str) -> std::io::Result<Vec<String>> {
self.sent.lock().unwrap().push(command.to_string());
if command.starts_with("split-window") {
Ok(vec![format!("%{}", self.new_pane)])
} else {
Ok(Vec::new())
}
}
}
#[test]
fn snippet_split_pane_action_splits_daemon_side() {
use crate::config::snippets::ActionSplitDirection;
let (mut ws, _plain, tab_id, pane) =
mux_state_with_recorder("pane-write-snippet-split", Config::default());
let (transport, sent) = SplitReplyingTransport::new(1);
ws.tmux_state.transport = Some(Box::new(transport));
assert!(
ws.execute_split_pane_action(
ActionSplitDirection::Vertical,
Some("echo snippet-split".to_string()),
false,
true,
0,
50,
"Split test".to_string(),
),
"the action must report success"
);
let commands: Vec<String> = sent
.lock()
.unwrap()
.iter()
.filter(|c| !c.starts_with("set-environment"))
.cloned()
.collect();
assert_eq!(
commands,
vec![
"split-window -h -t %0".to_string(),
format!(
"send-keys -t %1 -l {}",
par_term_mux::quote_env_value("echo snippet-split")
),
"send-keys -t %1 Enter".to_string(),
],
"the split and the command must both land daemon-side"
);
assert_eq!(
ws.tab_manager
.get_tab(tab_id)
.and_then(|tab| tab.pane_manager().map(|pm| pm.all_panes().len())),
Some(1),
"no native pane may be created inside the mux tab"
);
assert_eq!(
ws.tmux_state.mux_focused_pane,
Some(1),
"the new daemon pane must be focused"
);
let _ = pane;
}
}