use egui::RichText;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MuxSessionRow {
pub socket: PathBuf,
pub daemon: String,
pub id: u64,
pub name: String,
pub workspace_name: String,
}
impl MuxSessionRow {
pub fn survives_restore(&self) -> bool {
self.daemon == self.name
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MuxWorkspaceRow {
pub socket: PathBuf,
pub daemon: String,
pub id: u64,
pub name: String,
pub active: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MuxDirectory {
pub sessions: Vec<MuxSessionRow>,
pub workspaces: Vec<MuxWorkspaceRow>,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MuxPickerAction {
Attach(MuxSessionRow),
Create(String),
Rename(MuxSessionRow, String),
Kill(MuxSessionRow),
Detach,
NewWorkspace {
socket: PathBuf,
daemon: String,
name: String,
},
SwitchWorkspace(MuxWorkspaceRow),
RenameWorkspace(MuxWorkspaceRow, String),
KillWorkspace(MuxWorkspaceRow),
}
pub fn daemon_name_of(path: &Path) -> Option<String> {
let file = path.file_name()?.to_str()?;
let stem = file.strip_prefix("par-mux-")?.strip_suffix(".sock")?;
(!stem.is_empty()).then(|| stem.to_string())
}
pub fn free_session_name(existing: &[MuxSessionRow]) -> String {
(1..)
.map(|n| format!("session-{n}"))
.find(|candidate| {
!existing
.iter()
.any(|row| &row.name == candidate || &row.daemon == candidate)
})
.unwrap_or_else(|| "session".to_string())
}
pub fn session_name_refusal(name: &str) -> Option<&'static str> {
if name.is_empty() {
return Some("a session name is required");
}
if name
.chars()
.any(|c| c.is_whitespace() || matches!(c, '/' | '\\' | '\'' | '"' | ':'))
{
return Some("a session name cannot contain spaces, quotes, slashes, or colons");
}
None
}
pub fn workspace_name_refusal(name: &str) -> Option<&'static str> {
if name.is_empty() {
return Some("a workspace name is required");
}
if name.chars().any(|c| c == '\n' || c == '\r') {
return Some("a workspace name cannot contain line breaks");
}
None
}
pub struct MuxPickerInput<'a> {
pub directory: Option<&'a MuxDirectory>,
pub loading: bool,
pub attached: Option<(&'a str, &'a str)>,
}
#[derive(Default)]
pub struct MuxPickerSection {
new_name: String,
renaming: Option<(MuxSessionRow, String)>,
rename_first_frame: bool,
confirm_kill: Option<MuxSessionRow>,
name_error: Option<&'static str>,
ws_renaming: Option<(MuxWorkspaceRow, String)>,
ws_rename_first_frame: bool,
ws_confirm_kill: Option<MuxWorkspaceRow>,
ws_new_target: Option<(PathBuf, String, String)>,
ws_name_error: Option<&'static str>,
}
impl MuxPickerSection {
pub fn reset(&mut self) {
*self = Self::default();
}
pub fn show(
&mut self,
ui: &mut egui::Ui,
input: &MuxPickerInput<'_>,
) -> Option<MuxPickerAction> {
let mut action = None;
ui.heading("par-mux Sessions");
ui.separator();
if let Some((daemon, session)) = input.attached {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("This window: {session}")).strong());
if daemon != session {
ui.label(RichText::new(format!("(daemon {daemon})")).weak());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.button("Detach")
.on_hover_text("Leave the session running in the daemon")
.clicked()
{
action = Some(MuxPickerAction::Detach);
}
});
});
ui.add_space(4.0);
}
let sessions: &[MuxSessionRow] = input.directory.map_or(&[], |d| &d.sessions);
if sessions.is_empty() {
if input.loading || input.directory.is_none() {
ui.horizontal(|ui| {
ui.spinner();
ui.label(RichText::new("Loading sessions...").italics());
});
} else {
ui.label(RichText::new("No par-mux sessions running").italics());
}
}
for row in sessions {
if let Some(chosen) = self.show_row(ui, row, input.attached) {
action = Some(chosen);
}
}
if let Some(dir) = input.directory {
let mut seen: Vec<PathBuf> = Vec::new();
for ws in &dir.workspaces {
if seen.contains(&ws.socket) {
continue;
}
seen.push(ws.socket.clone());
ui.add_space(8.0);
ui.separator();
ui.label(
RichText::new(format!("Workspaces — {}", ws.daemon))
.weak()
.small(),
);
for w in dir.workspaces.iter().filter(|w| w.socket == ws.socket) {
if let Some(chosen) =
self.show_workspace_row(ui, w, input.attached, &dir.sessions)
{
action = Some(chosen);
}
}
if let Some(chosen) = self.show_new_workspace_row(ui, &ws.socket, &ws.daemon) {
action = Some(chosen);
}
}
}
if let Some(dir) = input.directory {
for error in &dir.errors {
ui.colored_label(crate::app::overlay::theme::DANGER, error);
}
}
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("New session:");
let response = ui.add(
egui::TextEdit::singleline(&mut self.new_name)
.desired_width(160.0)
.hint_text(free_session_name(sessions)),
);
let submit = response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if ui.button("Create").clicked() || submit {
let name = if self.new_name.trim().is_empty() {
free_session_name(sessions)
} else {
self.new_name.trim().to_string()
};
match session_name_refusal(&name) {
Some(reason) => self.name_error = Some(reason),
None => {
self.name_error = None;
action = Some(MuxPickerAction::Create(name));
}
}
}
});
if let Some(reason) = self.name_error {
ui.colored_label(crate::app::overlay::theme::DANGER, reason);
}
action
}
fn show_row(
&mut self,
ui: &mut egui::Ui,
row: &MuxSessionRow,
attached: Option<(&str, &str)>,
) -> Option<MuxPickerAction> {
let mut action = None;
let is_here = attached == Some((row.daemon.as_str(), row.name.as_str()));
if let Some((target, buffer)) = self.renaming.as_mut()
&& target == row
{
use crate::app::overlay::inline_edit::{self, InlineEditOutcome};
let first_frame = std::mem::take(&mut self.rename_first_frame);
let mut outcome = InlineEditOutcome::Editing;
ui.horizontal(|ui| {
let response = ui.add(egui::TextEdit::singleline(buffer).desired_width(160.0));
outcome = inline_edit::resolve(ui, &response, first_frame);
if ui.button("Cancel").clicked() {
outcome = InlineEditOutcome::Cancel;
}
});
match outcome {
InlineEditOutcome::Submit => {
action = Some(MuxPickerAction::Rename(
row.clone(),
buffer.trim().to_string(),
));
self.renaming = None;
}
InlineEditOutcome::Cancel => self.renaming = None,
InlineEditOutcome::Editing => {}
}
return action;
}
ui.horizontal(|ui| {
let name = if is_here {
RichText::new(&row.name).strong()
} else {
RichText::new(&row.name)
};
ui.label(name);
if !row.survives_restore() {
ui.label(RichText::new(format!("(daemon {})", row.daemon)).weak())
.on_hover_text("Attaches now; not reattached on the next launch");
}
if is_here {
ui.label(RichText::new("(this window)").color(crate::app::overlay::theme::SUCCESS));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let armed = self.confirm_kill.as_ref() == Some(row);
let end = if armed { "Confirm End" } else { "End" };
let end_btn = ui
.button(RichText::new(end).color(crate::app::overlay::theme::DANGER))
.on_hover_text("End the session and every program in it");
if end_btn.clicked() {
if armed {
self.confirm_kill = None;
action = Some(MuxPickerAction::Kill(row.clone()));
} else {
self.confirm_kill = Some(row.clone());
}
}
if ui.button("Rename").clicked() {
self.confirm_kill = None;
self.renaming = Some((row.clone(), row.name.clone()));
self.rename_first_frame = true;
}
if !is_here && ui.button("Attach").clicked() {
self.confirm_kill = None;
action = Some(MuxPickerAction::Attach(row.clone()));
}
});
});
action
}
fn show_workspace_row(
&mut self,
ui: &mut egui::Ui,
ws: &MuxWorkspaceRow,
attached: Option<(&str, &str)>,
sessions: &[MuxSessionRow],
) -> Option<MuxPickerAction> {
let mut action = None;
let ours = attached.is_some_and(|(daemon, _)| daemon == ws.daemon);
if let Some((target, buffer)) = self.ws_renaming.as_mut()
&& target == ws
{
use crate::app::overlay::inline_edit::{self, InlineEditOutcome};
let first_frame = std::mem::take(&mut self.ws_rename_first_frame);
let mut outcome = InlineEditOutcome::Editing;
ui.horizontal(|ui| {
let response = ui.add(egui::TextEdit::singleline(buffer).desired_width(160.0));
outcome = inline_edit::resolve(ui, &response, first_frame);
if ui.button("Cancel").clicked() {
outcome = InlineEditOutcome::Cancel;
}
});
match outcome {
InlineEditOutcome::Submit => {
action = Some(MuxPickerAction::RenameWorkspace(
ws.clone(),
buffer.trim().to_string(),
));
self.ws_renaming = None;
}
InlineEditOutcome::Cancel => self.ws_renaming = None,
InlineEditOutcome::Editing => {}
}
return action;
}
ui.horizontal(|ui| {
let label = if ws.active {
RichText::new(format!("{} (active)", ws.name)).strong()
} else {
RichText::new(&ws.name)
};
ui.label(label);
if ours {
ui.label(RichText::new("(this daemon)").weak());
}
let session_count = sessions
.iter()
.filter(|s| s.socket == ws.socket && s.workspace_name == ws.name)
.count();
if session_count > 0 {
ui.label(
RichText::new(format!(
"({session_count} session{})",
if session_count == 1 { "" } else { "s" }
))
.weak(),
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let armed = self.ws_confirm_kill.as_ref() == Some(ws);
let end = if armed { "Confirm End" } else { "End" };
if ui
.button(RichText::new(end).color(crate::app::overlay::theme::DANGER))
.on_hover_text("End the workspace and every session in it")
.clicked()
{
if armed {
self.ws_confirm_kill = None;
action = Some(MuxPickerAction::KillWorkspace(ws.clone()));
} else {
self.ws_confirm_kill = Some(ws.clone());
}
}
if ui.button("Rename").clicked() {
self.ws_confirm_kill = None;
self.ws_renaming = Some((ws.clone(), ws.name.clone()));
self.ws_rename_first_frame = true;
}
if !ws.active && ui.button("Switch").clicked() {
self.ws_confirm_kill = None;
action = Some(MuxPickerAction::SwitchWorkspace(ws.clone()));
}
});
});
action
}
fn show_new_workspace_row(
&mut self,
ui: &mut egui::Ui,
socket: &Path,
daemon: &str,
) -> Option<MuxPickerAction> {
let open = self
.ws_new_target
.as_ref()
.is_some_and(|(s, d, _)| s == socket && d == daemon);
if !open {
if ui.button("New workspace").clicked() {
self.ws_new_target =
Some((socket.to_path_buf(), daemon.to_string(), String::new()));
self.ws_name_error = None;
}
return None;
}
let mut buffer = self
.ws_new_target
.as_ref()
.map(|(_, _, b)| b.clone())
.unwrap_or_default();
let mut chosen: Option<MuxPickerAction> = None;
ui.horizontal(|ui| {
let response = ui.add(
egui::TextEdit::singleline(&mut buffer)
.desired_width(160.0)
.hint_text("workspace name"),
);
let submit = response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if ui.button("Create").clicked() || submit {
let name = buffer.trim().to_string();
match workspace_name_refusal(&name) {
Some(reason) => self.ws_name_error = Some(reason),
None => {
self.ws_new_target = None;
chosen = Some(MuxPickerAction::NewWorkspace {
socket: socket.to_path_buf(),
daemon: daemon.to_string(),
name,
});
}
}
}
if ui.button("Cancel").clicked() {
self.ws_new_target = None;
}
});
if let Some((_, _, b)) = self.ws_new_target.as_mut() {
*b = buffer;
}
if let Some(reason) = self.ws_name_error {
ui.colored_label(crate::app::overlay::theme::DANGER, reason);
}
chosen
}
}
#[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 socket_names_decode_to_their_daemon() {
assert_eq!(
daemon_name_of(Path::new("/tmp/x/par-mux-work.sock")),
Some("work".to_string())
);
assert_eq!(daemon_name_of(Path::new("/tmp/x/par-mux-.sock")), None);
assert_eq!(daemon_name_of(Path::new("/tmp/x/other.sock")), None);
assert_eq!(daemon_name_of(Path::new("/tmp/x/par-mux-work.state")), None);
}
#[test]
fn free_names_skip_taken_sessions_and_daemons() {
assert_eq!(free_session_name(&[]), "session-1");
assert_eq!(
free_session_name(&[row("session-1", "session-1"), row("x", "session-2")]),
"session-3"
);
}
#[test]
fn session_names_that_cannot_be_socket_names_are_refused() {
assert!(session_name_refusal("").is_some());
assert!(session_name_refusal("a/b").is_some());
assert!(session_name_refusal("a b").is_some());
assert!(session_name_refusal("it's").is_some());
assert_eq!(session_name_refusal("work-2"), None);
}
#[test]
fn only_a_session_named_after_its_daemon_survives_restore() {
assert!(row("work", "work").survives_restore());
assert!(!row("default", "work").survives_restore());
}
}