use super::mux::MuxAttachPending;
use super::mux_directory::{self, MuxDirectory, MuxSessionRow, MuxWorkspaceRow, PendingScan};
use crate::app::window_state::WindowState;
use std::path::{Path, PathBuf};
pub(crate) use crate::session_picker_mux::MuxPickerAction as MuxSessionRequest;
use crate::session_picker_mux::MuxPickerAction;
impl WindowState {
pub(crate) fn mux_socket_dir(&self) -> Option<PathBuf> {
let override_dir = self.tmux_state.mux_socket_dir_override.clone();
if cfg!(test) {
return override_dir;
}
override_dir.or_else(mux_directory::default_socket_dir)
}
fn mux_socket_for_new(&self, name: &str) -> Option<PathBuf> {
Some(self.mux_socket_dir()?.join(format!("par-mux-{name}.sock")))
}
pub(crate) fn refresh_mux_directory(&mut self) {
if self.tmux_state.mux_directory_scan.is_some() {
return;
}
if let Some(dir) = self.mux_socket_dir() {
self.tmux_state.mux_directory_scan = PendingScan::start(dir);
}
}
pub(crate) fn poll_mux_directory(&mut self) -> bool {
let Some(scan) = self.tmux_state.mux_directory_scan.as_ref() else {
return false;
};
match scan.poll() {
None => false,
Some(result) => {
self.tmux_state.mux_directory_scan = None;
self.tmux_state.mux_directory = Some(result.unwrap_or_else(|()| MuxDirectory {
sessions: Vec::new(),
workspaces: Vec::new(),
errors: vec!["par-mux session scan stopped unexpectedly".to_string()],
}));
true
}
}
}
pub(crate) fn begin_mux_attach_at(&mut self, socket: &Path, name: &str, spawn: bool) {
if self.tmux_state.mux_attach_pending.is_some() {
self.show_toast("par-mux: an attach is already in progress");
return;
}
if let Some(reason) = super::mux::mux_attach_refusal(socket) {
log::error!("par-mux attach to '{name}' refused: {reason}");
self.record_mux_error(format!("par-mux: attach to '{name}' refused — {reason}"));
return;
}
self.tmux_state.mux_daemon_rx = None;
self.tmux_state.mux_attach_socket = Some(socket.to_path_buf());
let (tx, rx) = std::sync::mpsc::channel();
let socket = socket.to_path_buf();
let spawned = std::thread::Builder::new()
.name("mux-attach".into())
.spawn(move || {
let client = if spawn {
par_term_emu_core_rust::mux::MuxClient::connect_or_spawn_at(&socket)
} else {
par_term_emu_core_rust::mux::MuxClient::connect(&socket)
};
let _ = tx.send(client);
});
match spawned {
Ok(_) => {
self.tmux_state.mux_attach_pending = Some(MuxAttachPending {
name: name.to_string(),
rx,
});
}
Err(e) => {
log::error!("mux attach worker could not start: {e}");
self.tmux_state.mux_attach_socket = None;
self.record_mux_error("par-mux: attach failed (worker thread unavailable)");
}
}
}
fn detach_for_switch(&mut self) {
if self.tmux_state.transport.is_none() {
return;
}
self.detach_mux_session();
if self.tab_manager.tab_count() == 0 {
self.new_tab();
if let Some(tab) = self.tab_manager.tabs().first() {
self.tmux_state.mux_restore_placeholder_tab = Some(tab.id);
}
}
}
pub(crate) fn handle_mux_session_request(&mut self, request: MuxSessionRequest) {
match request {
MuxSessionRequest::Attach(row) => {
let already_here = self.tmux_state.transport.is_some()
&& self.tmux_state.tmux_session_name.as_deref() == Some(row.name.as_str())
&& self.tmux_state.mux_daemon.as_deref() == Some(row.daemon.as_str());
if already_here {
self.show_toast(format!("par-mux: already attached to '{}'", row.name));
return;
}
self.detach_for_switch();
self.tmux_state.mux_daemon = Some(row.daemon.clone());
self.begin_mux_attach_at(&row.socket, &row.name, false);
if !row.survives_restore() {
self.show_toast(format!(
"par-mux: attaching to '{}' (daemon '{}'); it is not reattached on \
the next launch",
row.name, row.daemon
));
}
}
MuxSessionRequest::Create(name) => {
if let Some(reason) = crate::session_picker_mux::session_name_refusal(&name) {
self.show_toast(format!("par-mux: {reason}"));
return;
}
let taken = self
.tmux_state
.mux_directory
.as_ref()
.is_some_and(|d| d.sessions.iter().any(|s| s.name == name));
if taken {
self.show_toast(format!(
"par-mux: a session named '{name}' already exists — attach to it instead"
));
return;
}
let Some(socket) = self.mux_socket_for_new(&name) else {
self.show_toast("par-mux: no socket directory is available");
return;
};
self.detach_for_switch();
self.tmux_state.mux_daemon = Some(name.clone());
self.begin_mux_attach_at(&socket, &name, true);
}
MuxSessionRequest::Rename(row, new_name) => {
self.rename_mux_session(&row, &new_name);
}
MuxSessionRequest::Kill(row) => {
self.kill_mux_session(&row);
}
MuxSessionRequest::Detach => {
if !self.detach_mux_session() {
self.show_toast("Not attached to a par-mux session");
}
}
MuxSessionRequest::NewWorkspace {
socket,
daemon,
name,
} => {
self.new_mux_workspace(&socket, &daemon, &name);
}
MuxSessionRequest::SwitchWorkspace(ws) => {
self.switch_mux_workspace(&ws);
}
MuxSessionRequest::RenameWorkspace(ws, new_name) => {
self.rename_mux_workspace(&ws, &new_name);
}
MuxSessionRequest::KillWorkspace(ws) => {
self.kill_mux_workspace(&ws);
}
}
self.focus_state.needs_redraw = true;
self.request_redraw();
}
fn send_to_daemon(
&mut self,
socket: &Path,
daemon: &str,
command: &str,
) -> Result<Vec<String>, String> {
let ours = self.tmux_state.transport.is_some()
&& self.tmux_state.mux_daemon.as_deref() == Some(daemon);
let reply = if ours {
self.tmux_state
.transport
.as_ref()
.map(|t| t.send_command(command))
.unwrap_or_else(|| Err(std::io::Error::other("no transport")))
} else {
par_term_mux::MuxSessionClient::connect(socket).and_then(|mut c| c.send(command))
};
match reply {
Ok(body) if body.iter().any(|l| l.starts_with("%error")) => Err(body.join(" ")),
Ok(body) => Ok(body),
Err(e) => Err(e.to_string()),
}
}
fn send_to_session_daemon(&mut self, row: &MuxSessionRow, command: &str) -> Result<(), String> {
self.send_to_daemon(&row.socket, &row.daemon, command)
.map(|_| ())
}
fn new_mux_workspace(&mut self, socket: &Path, daemon: &str, name: &str) {
if let Some(reason) = crate::session_picker_mux::workspace_name_refusal(name) {
self.show_toast(format!("par-mux: {reason}"));
return;
}
let command = format!("new-workspace -n {}", par_term_mux::quote_env_value(name));
match self.send_to_daemon(socket, daemon, &command) {
Ok(_) => {
self.show_toast(format!("par-mux: created workspace '{name}'"));
self.refresh_mux_directory();
}
Err(e) => self.record_mux_error(format!("par-mux: new-workspace failed — {e}")),
}
}
fn switch_mux_workspace(&mut self, ws: &MuxWorkspaceRow) {
let ours = self.tmux_state.transport.is_some()
&& self.tmux_state.mux_daemon.as_deref() == Some(ws.daemon.as_str());
match self.send_to_daemon(
&ws.socket,
&ws.daemon,
&format!("select-workspace -t +{}", ws.id),
) {
Ok(_) => {
self.show_toast(format!("par-mux: switched to workspace '{}'", ws.name));
self.refresh_mux_directory();
if ours {
self.follow_displayed_mux_session();
}
}
Err(e) => self.record_mux_error(format!("par-mux: select-workspace failed — {e}")),
}
}
fn follow_displayed_mux_session(&mut self) {
let Some(transport) = self.tmux_state.transport.as_ref() else {
return;
};
let displayed = transport
.send_command("switch-client")
.ok()
.and_then(|lines| lines.into_iter().find(|l| !l.trim().is_empty()))
.and_then(|l| {
l.trim()
.strip_prefix('$')
.and_then(|r| r.parse::<u64>().ok())
});
let Some(displayed) = displayed else {
return;
};
let daemon = self.tmux_state.mux_daemon.clone().unwrap_or_default();
let socket = self.tmux_state.mux_attach_socket.clone().or_else(|| {
self.mux_socket_dir()
.map(|dir| dir.join(format!("par-mux-{daemon}.sock")))
});
let Some(socket) = socket else {
return;
};
let row = par_term_mux::MuxSessionClient::connect(&socket)
.and_then(|mut c| c.list_sessions())
.ok()
.and_then(|sessions| {
sessions.into_iter().find(|s| s.id == displayed).map(|s| {
crate::session_picker_mux::MuxSessionRow {
socket: socket.clone(),
daemon: daemon.clone(),
id: s.id,
name: s.name,
workspace_name: s.workspace_name,
}
})
});
let Some(row) = row else {
return;
};
self.handle_mux_session_request(MuxPickerAction::Attach(row));
}
fn rename_mux_workspace(&mut self, ws: &MuxWorkspaceRow, new_name: &str) {
if let Some(reason) = crate::session_picker_mux::workspace_name_refusal(new_name) {
self.show_toast(format!("par-mux: {reason}"));
return;
}
let command = format!(
"rename-workspace -t +{} {}",
ws.id,
par_term_mux::quote_env_value(new_name)
);
match self.send_to_daemon(&ws.socket, &ws.daemon, &command) {
Ok(_) => {
self.show_toast(format!(
"par-mux: renamed workspace '{}' to '{new_name}'",
ws.name
));
self.refresh_mux_directory();
}
Err(e) => self.record_mux_error(format!("par-mux: rename-workspace failed — {e}")),
}
}
fn kill_mux_workspace(&mut self, ws: &MuxWorkspaceRow) {
match self.send_to_daemon(
&ws.socket,
&ws.daemon,
&format!("kill-workspace -t +{}", ws.id),
) {
Ok(_) => {
self.show_toast(format!("par-mux: ended workspace '{}'", ws.name));
self.refresh_mux_directory();
}
Err(e) => self.record_mux_error(format!("par-mux: kill-workspace failed — {e}")),
}
}
fn rename_mux_session(&mut self, row: &MuxSessionRow, new_name: &str) {
if let Some(reason) = crate::session_picker_mux::session_name_refusal(new_name) {
self.show_toast(format!("par-mux: {reason}"));
return;
}
let command = format!(
"rename-session -t ${} {}",
row.id,
par_term_mux::quote_env_value(new_name)
);
match self.send_to_session_daemon(row, &command) {
Ok(()) => {
self.show_toast(format!("par-mux: renamed '{}' to '{new_name}'", row.name));
self.refresh_mux_directory();
}
Err(e) => self.record_mux_error(format!("par-mux: rename failed — {e}")),
}
}
fn kill_mux_session(&mut self, row: &MuxSessionRow) {
match self.send_to_session_daemon(row, &format!("kill-session -t ${}", row.id)) {
Ok(()) => {
self.show_toast(format!("par-mux: ended session '{}'", row.name));
self.refresh_mux_directory();
}
Err(e) => self.record_mux_error(format!("par-mux: kill-session failed — {e}")),
}
}
pub(crate) fn attach_mux_session_by_name(&mut self, name: &str) {
self.attach_by_name(name, true);
}
fn attach_by_name(&mut self, name: &str, create_missing: bool) {
if let Some(reason) = crate::session_picker_mux::session_name_refusal(name) {
self.show_toast(format!("par-mux: cannot attach to '{name}' — {reason}"));
return;
}
if self.tmux_state.mux_attach_pending.is_some() {
self.show_toast("par-mux: an attach is already in progress");
return;
}
let Some(dir) = self.mux_socket_dir() else {
self.show_toast("par-mux: no socket directory is available");
return;
};
self.detach_for_switch();
self.tmux_state.mux_attach_socket = None;
let (tx, rx) = std::sync::mpsc::channel();
let (daemon_tx, daemon_rx) = std::sync::mpsc::channel();
let wanted = name.to_string();
let spawned = std::thread::Builder::new()
.name("mux-attach".into())
.spawn(move || {
let listed = mux_directory::scan(&dir, mux_directory::DAEMON_QUERY_DEADLINE)
.sessions
.into_iter()
.find(|s| s.name == wanted);
let client = match listed {
Some(row) => {
let _ = daemon_tx.send((row.daemon.clone(), row.socket.clone()));
par_term_emu_core_rust::mux::MuxClient::connect(&row.socket)
}
None if create_missing => {
let socket = dir.join(format!("par-mux-{wanted}.sock"));
let _ = daemon_tx.send((wanted.clone(), socket.clone()));
par_term_emu_core_rust::mux::MuxClient::connect_or_spawn_at(&socket)
}
None => Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("no running session named '{wanted}'"),
)),
};
let _ = tx.send(client);
});
match spawned {
Ok(_) => {
self.tmux_state.mux_daemon_rx = Some(daemon_rx);
self.tmux_state.mux_attach_pending = Some(MuxAttachPending {
name: name.to_string(),
rx,
});
}
Err(e) => {
log::error!("mux attach worker could not start: {e}");
self.record_mux_error("par-mux: attach failed (worker thread unavailable)");
}
}
}
pub(crate) fn dispatch_attach_mux_session(&mut self, action: &str) -> bool {
let resolved =
resolve_attach_id(action, self.tmux_state.mux_directory.as_ref()).map(|r| r.cloned());
match resolved {
Some(Ok(row)) => {
self.handle_mux_session_request(MuxSessionRequest::Attach(row));
true
}
Some(Err(name)) => {
self.attach_by_name(&name, false);
true
}
None => false,
}
}
pub(crate) fn mux_session_palette_rows(
tmux_state: &crate::app::tmux_handler::tmux_state::TmuxState,
) -> Vec<crate::command_palette::catalog::PaletteEntry> {
let mut rows = Vec::new();
let attached = tmux_state
.transport
.as_ref()
.and(tmux_state.tmux_session_name.as_deref());
if let Some(dir) = &tmux_state.mux_directory {
for session in &dir.sessions {
if attached == Some(session.name.as_str())
&& tmux_state.mux_daemon.as_deref() == Some(session.daemon.as_str())
{
continue;
}
let label = if session.survives_restore() {
format!("Attach par-mux Session: {}", session.name)
} else {
format!(
"Attach par-mux Session: {} (daemon {})",
session.name, session.daemon
)
};
rows.push(crate::command_palette::catalog::PaletteEntry {
action_id: attach_action_id(session),
label,
chord: None,
priority: 0,
});
}
}
rows
}
}
pub(crate) fn attach_action_id(row: &MuxSessionRow) -> String {
format!("attach_mux_session:{}/{}", row.daemon, row.name)
}
pub(crate) fn resolve_attach_id<'a>(
id: &str,
directory: Option<&'a MuxDirectory>,
) -> Option<Result<&'a MuxSessionRow, String>> {
let spec = id.strip_prefix("attach_mux_session:")?;
let (daemon, name) = match spec.split_once('/') {
Some((daemon, name)) => (Some(daemon), name),
None => (None, spec),
};
let found = directory.and_then(|d| {
d.sessions
.iter()
.find(|s| s.name == name && daemon.is_none_or(|dn| dn == s.daemon))
});
Some(found.ok_or_else(|| name.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn row(daemon: &str, name: &str) -> MuxSessionRow {
MuxSessionRow {
socket: PathBuf::from(format!("/s/par-mux-{daemon}.sock")),
daemon: daemon.to_string(),
id: 0,
name: name.to_string(),
workspace_name: String::new(),
}
}
#[test]
fn attach_ids_round_trip_through_the_directory() {
let dir = MuxDirectory {
sessions: vec![row("work", "work"), row("default", "scratch")],
workspaces: vec![],
errors: vec![],
};
let id = attach_action_id(&dir.sessions[1]);
assert_eq!(id, "attach_mux_session:default/scratch");
assert_eq!(
resolve_attach_id(&id, Some(&dir)),
Some(Ok(&dir.sessions[1]))
);
assert_eq!(
resolve_attach_id("attach_mux_session:work", Some(&dir)),
Some(Ok(&dir.sessions[0])),
"a bare name resolves by session name"
);
assert_eq!(
resolve_attach_id("attach_mux_session:gone", Some(&dir)),
Some(Err("gone".to_string()))
);
assert_eq!(resolve_attach_id("new_tab", Some(&dir)), None);
}
}