diff options
| author | user <user@clank> | 2026-08-12 19:34:15 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 19:34:15 +0200 |
| commit | d1c14ff0f5c5b55c46d91ad12f4a291041480a99 (patch) | |
| tree | 9dca9628d228b211a6919c9dd65cd7a9b4fcf6b0 /sl0ppij/zellij-utils/src/sessions.rs | |
| parent | chore: initialize sl0ppi monorepo (diff) | |
| download | sl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.tar.gz sl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.tar.xz sl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.zip | |
vendor(sl0ppij): import zellij v0.45.0 tree
Squashed import of upstream zellij at 0e6e4404 ("Support the Kitty Image
Protocol (#5428)"), the merge-base our fork was developed against. Upstream's
3311 commits are deliberately NOT replayed here; only our own fork commits are,
so the monorepo stays small and `git log sl0ppij/` shows sl0p work, not zellij's
entire history.
Upstream provenance is tracked in FORKS.md and can always be re-fetched from
https://github.com/zellij-org/zellij.
Diffstat (limited to 'sl0ppij/zellij-utils/src/sessions.rs')
| -rw-r--r-- | sl0ppij/zellij-utils/src/sessions.rs | 748 |
1 files changed, 748 insertions, 0 deletions
diff --git a/sl0ppij/zellij-utils/src/sessions.rs b/sl0ppij/zellij-utils/src/sessions.rs new file mode 100644 index 0000000..7e42077 --- /dev/null +++ b/sl0ppij/zellij-utils/src/sessions.rs @@ -0,0 +1,748 @@ +use crate::{ + consts::{ + is_ipc_socket, session_info_folder_for_session, session_layout_cache_file_name, + ZELLIJ_SESSION_INFO_CACHE_DIR, ZELLIJ_SOCK_DIR, + }, + envs, + input::layout::Layout, + ipc::{ClientToServerMsg, IpcReceiverWithContext, IpcSenderWithContext, ServerToClientMsg}, +}; +use anyhow; +use humantime::format_duration; +use std::collections::HashMap; +use std::time::{Duration, SystemTime}; +use std::{fs, io, process}; +use suggest::Suggest; + +pub fn get_sessions() -> Result<Vec<(String, Duration)>, io::ErrorKind> { + match fs::read_dir(&*ZELLIJ_SOCK_DIR) { + Ok(files) => { + let mut sessions = Vec::new(); + files.for_each(|file| { + if let Ok(file) = file { + let file_name = file.file_name().into_string().unwrap(); + // try to get creation time, fall back to modification time on platforms where it's not supported (e.g., musl) + // for session creation time these are almost always identical (notable + // exceptions are session name changes) + let ctime = std::fs::metadata(&file.path()) + .ok() + .and_then(|f| f.created().ok().or_else(|| f.modified().ok())) + .and_then(|d| d.elapsed().ok()) + .unwrap_or_default(); + let duration = Duration::from_secs(ctime.as_secs()); + if is_ipc_socket(&file.file_type().unwrap()) && assert_socket(&file_name) { + sessions.push((file_name, duration)); + } + } + }); + Ok(sessions) + }, + Err(err) if io::ErrorKind::NotFound != err.kind() => Err(err.kind()), + Err(_) => Ok(Vec::with_capacity(0)), + } +} + +pub fn get_resurrectable_sessions() -> Vec<(String, Duration)> { + match fs::read_dir(&*ZELLIJ_SESSION_INFO_CACHE_DIR) { + Ok(files_in_session_info_folder) => { + let files_that_are_folders = files_in_session_info_folder + .filter_map(|f| f.ok().map(|f| f.path())) + .filter(|f| f.is_dir()); + files_that_are_folders + .filter_map(|folder_name| { + let layout_file_name = + session_layout_cache_file_name(&folder_name.display().to_string()); + // Try to get creation time, fall back to modification time on platforms where it's not supported (e.g., musl) + let ctime = std::fs::metadata(&layout_file_name) + .ok() + .and_then(|metadata| { + metadata.created().ok().or_else(|| metadata.modified().ok()) + }); + let elapsed_duration = ctime + .map(|ctime| { + Duration::from_secs(ctime.elapsed().ok().unwrap_or_default().as_secs()) + }) + .unwrap_or_default(); + let session_name = folder_name + .file_name() + .map(|f| std::path::PathBuf::from(f).display().to_string())?; + if std::path::Path::new(&layout_file_name).exists() { + Some((session_name, elapsed_duration)) + } else { + None + } + }) + .collect() + }, + Err(e) => { + log::error!( + "Failed to read session_info cache folder: \"{:?}\": {:?}", + &*ZELLIJ_SESSION_INFO_CACHE_DIR, + e + ); + vec![] + }, + } +} + +pub fn get_resurrectable_session_names() -> Vec<String> { + match fs::read_dir(&*ZELLIJ_SESSION_INFO_CACHE_DIR) { + Ok(files_in_session_info_folder) => { + let files_that_are_folders = files_in_session_info_folder + .filter_map(|f| f.ok().map(|f| f.path())) + .filter(|f| f.is_dir()); + files_that_are_folders + .filter_map(|folder_name| { + let folder = folder_name.display().to_string(); + let resurrection_layout_file = session_layout_cache_file_name(&folder); + if std::path::Path::new(&resurrection_layout_file).exists() { + folder_name + .file_name() + .map(|f| format!("{}", f.to_string_lossy())) + } else { + None + } + }) + .collect() + }, + Err(e) => { + log::error!( + "Failed to read session_info cache folder: \"{:?}\": {:?}", + &*ZELLIJ_SESSION_INFO_CACHE_DIR, + e + ); + vec![] + }, + } +} + +pub fn get_sessions_sorted_by_mtime() -> anyhow::Result<Vec<String>> { + match fs::read_dir(&*ZELLIJ_SOCK_DIR) { + Ok(files) => { + let mut sessions_with_mtime: Vec<(String, SystemTime)> = Vec::new(); + for file in files { + let file = file?; + let file_name = file.file_name().into_string().unwrap(); + let file_modified_at = file.metadata()?.modified()?; + if is_ipc_socket(&file.file_type()?) && assert_socket(&file_name) { + sessions_with_mtime.push((file_name, file_modified_at)); + } + } + sessions_with_mtime.sort_by_key(|x| x.1); // the oldest one will be the first + + let sessions = sessions_with_mtime.iter().map(|x| x.0.clone()).collect(); + Ok(sessions) + }, + Err(err) if io::ErrorKind::NotFound != err.kind() => Err(err.into()), + Err(_) => Ok(Vec::with_capacity(0)), + } +} + +/// Probe a session socket to check if a server is alive. +/// +/// On Unix, connects and sends a `ConnStatus` message to verify the server responds. +/// On Windows, reads the server PID from the marker file and checks process liveness. +#[cfg(unix)] +fn assert_socket(name: &str) -> bool { + use crate::consts::ipc_connect; + let path = &*ZELLIJ_SOCK_DIR.join(name); + match ipc_connect(path) { + Ok(stream) => { + let mut sender: IpcSenderWithContext<ClientToServerMsg> = + IpcSenderWithContext::new(stream); + let _ = sender.send_client_msg(ClientToServerMsg::ConnStatus); + let mut receiver: IpcReceiverWithContext<ServerToClientMsg> = sender.get_receiver(); + match receiver.recv_server_msg() { + Some((ServerToClientMsg::Connected, _)) => true, + None | Some((_, _)) => false, + } + }, + Err(e) if e.kind() == io::ErrorKind::ConnectionRefused => { + drop(fs::remove_file(path)); + false + }, + Err(_) => false, + } +} + +/// On Windows, reads the server PID from the marker file and checks whether +/// the process is still alive via `OpenProcess`. Cleans up stale marker files. +#[cfg(windows)] +fn assert_socket(name: &str) -> bool { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}; + + let path = &*ZELLIJ_SOCK_DIR.join(name); + let pid_str = match fs::read_to_string(path) { + Ok(s) => s, + Err(_) => { + drop(fs::remove_file(path)); + return false; + }, + }; + let pid: u32 = match pid_str.trim().parse() { + Ok(p) => p, + Err(_) => { + // Marker file exists but has no valid PID (e.g. empty from old version). + // Treat as stale. + drop(fs::remove_file(path)); + return false; + }, + }; + let alive = unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle.is_null() { + false + } else { + CloseHandle(handle); + true + } + }; + if !alive { + drop(fs::remove_file(path)); + } + alive +} + +#[cfg(not(any(unix, windows)))] +fn assert_socket(_name: &str) -> bool { + true +} + +pub fn print_sessions( + mut sessions: Vec<(String, Duration, bool)>, + no_formatting: bool, + short: bool, + reverse: bool, +) { + // (session_name, timestamp, is_dead) + let curr_session = envs::get_session_name().unwrap_or_else(|_| "".into()); + sessions.sort_by(|a, b| { + if reverse { + // sort by `Duration` ascending (newest would be first) + a.1.cmp(&b.1) + } else { + b.1.cmp(&a.1) + } + }); + sessions + .iter() + .for_each(|(session_name, timestamp, is_dead)| { + if short { + println!("{}", session_name); + return; + } + if no_formatting { + let suffix = if curr_session == *session_name { + format!("(current)") + } else if *is_dead { + format!("(EXITED - attach to resurrect)") + } else { + String::new() + }; + let timestamp = format!("[Created {} ago]", format_duration(*timestamp)); + println!("{} {} {}", session_name, timestamp, suffix); + } else { + let formatted_session_name = format!("\u{1b}[32;1m{}\u{1b}[m", session_name); + let suffix = if curr_session == *session_name { + format!("(current)") + } else if *is_dead { + format!("(\u{1b}[31;1mEXITED\u{1b}[m - attach to resurrect)") + } else { + String::new() + }; + let timestamp = format!( + "[Created \u{1b}[35;1m{}\u{1b}[m ago]", + format_duration(*timestamp) + ); + println!("{} {} {}", formatted_session_name, timestamp, suffix); + } + }) +} + +pub fn print_sessions_with_index(sessions: Vec<String>) { + let curr_session = envs::get_session_name().unwrap_or_else(|_| "".into()); + for (i, session) in sessions.iter().enumerate() { + let suffix = if curr_session == *session { + " (current)" + } else { + "" + }; + println!("{}: {}{}", i, session, suffix); + } +} + +pub enum ActiveSession { + None, + One(String), + Many, +} + +pub fn get_active_session() -> ActiveSession { + match get_sessions() { + Ok(sessions) if sessions.is_empty() => ActiveSession::None, + Ok(mut sessions) if sessions.len() == 1 => ActiveSession::One(sessions.pop().unwrap().0), + Ok(_) => ActiveSession::Many, + Err(e) => { + eprintln!("Error occurred: {:?}", e); + process::exit(1); + }, + } +} + +pub fn kill_session(name: &str) { + use crate::consts::ipc_connect; + let path = &*ZELLIJ_SOCK_DIR.join(name); + match ipc_connect(path) { + Ok(stream) => { + // On Windows, the server uses a dual-pipe architecture: the main pipe + // for client→server and a reply pipe for server→client. We must: + // 1. Connect to the reply pipe (so the server unblocks from + // reply_listener.accept() and spawns the route thread) + // 2. Send KillSession on the main pipe + // 3. Wait for the Exit response on the reply pipe (so we don't + // disconnect before the server processes the message) + #[cfg(windows)] + { + let reply = crate::consts::ipc_connect_reply(path); + let _ = IpcSenderWithContext::<ClientToServerMsg>::new(stream) + .send_client_msg(ClientToServerMsg::KillSession); + if let Ok(reply_stream) = reply { + let mut receiver: IpcReceiverWithContext<ServerToClientMsg> = + IpcReceiverWithContext::new(reply_stream); + let _ = receiver.recv_server_msg(); + } + } + #[cfg(not(windows))] + { + let _ = IpcSenderWithContext::<ClientToServerMsg>::new(stream) + .send_client_msg(ClientToServerMsg::KillSession); + } + }, + Err(e) => { + eprintln!("Error occurred: {:?}", e); + process::exit(1); + }, + }; +} + +pub fn delete_session(name: &str, force: bool) { + if force { + use crate::consts::ipc_connect; + let path = &*ZELLIJ_SOCK_DIR.join(name); + let _ = ipc_connect(path).ok().map(|stream| { + #[cfg(windows)] + { + let reply = crate::consts::ipc_connect_reply(path); + let _ = IpcSenderWithContext::<ClientToServerMsg>::new(stream) + .send_client_msg(ClientToServerMsg::KillSession); + if let Ok(reply_stream) = reply { + let mut receiver: IpcReceiverWithContext<ServerToClientMsg> = + IpcReceiverWithContext::new(reply_stream); + let _ = receiver.recv_server_msg(); + } + } + #[cfg(not(windows))] + { + IpcSenderWithContext::<ClientToServerMsg>::new(stream) + .send_client_msg(ClientToServerMsg::KillSession) + .ok(); + } + }); + } + if let Err(e) = std::fs::remove_dir_all(session_info_folder_for_session(name)) { + if e.kind() == std::io::ErrorKind::NotFound { + eprintln!("Session: {:?} not found.", name); + process::exit(2); + } else { + log::error!("Failed to remove session {:?}: {:?}", name, e); + } + } else { + println!("Session: {:?} successfully deleted.", name); + } +} + +pub fn list_sessions(no_formatting: bool, short: bool, reverse: bool) { + let exit_code = match get_sessions() { + Ok(running_sessions) => { + let resurrectable_sessions = get_resurrectable_sessions(); + let mut all_sessions: HashMap<String, (Duration, bool)> = resurrectable_sessions + .iter() + .map(|(name, timestamp)| (name.clone(), (timestamp.clone(), true))) + .collect(); + for (session_name, duration) in running_sessions { + all_sessions.insert(session_name.clone(), (duration, false)); + } + if all_sessions.is_empty() { + eprintln!("No active zellij sessions found."); + 1 + } else { + print_sessions( + all_sessions + .iter() + .map(|(name, (timestamp, is_dead))| { + (name.clone(), timestamp.clone(), *is_dead) + }) + .collect(), + no_formatting, + short, + reverse, + ); + 0 + } + }, + Err(e) => { + eprintln!("Error occurred: {:?}", e); + 1 + }, + }; + process::exit(exit_code); +} + +#[derive(Debug, Clone)] +pub enum SessionNameMatch { + AmbiguousPrefix(Vec<String>), + UniquePrefix(String), + Exact(String), + None, +} + +pub fn match_session_name(prefix: &str) -> Result<SessionNameMatch, io::ErrorKind> { + let sessions = get_sessions()?; + + let filtered_sessions: Vec<_> = sessions + .iter() + .filter(|s| s.0.starts_with(prefix)) + .collect(); + + if filtered_sessions.iter().any(|s| s.0 == prefix) { + return Ok(SessionNameMatch::Exact(prefix.to_string())); + } + + Ok({ + match &filtered_sessions[..] { + [] => SessionNameMatch::None, + [s] => SessionNameMatch::UniquePrefix(s.0.to_string()), + _ => SessionNameMatch::AmbiguousPrefix( + filtered_sessions.into_iter().map(|s| s.0.clone()).collect(), + ), + } + }) +} + +pub fn session_exists(name: &str) -> Result<bool, io::ErrorKind> { + match match_session_name(name) { + Ok(SessionNameMatch::Exact(_)) => Ok(true), + Ok(_) => Ok(false), + Err(e) => Err(e), + } +} + +// if the session is resurrecable, the returned layout is the one to be used to resurrect it +pub fn resurrection_layout(session_name_to_resurrect: &str) -> Result<Option<Layout>, String> { + let layout_file_name = session_layout_cache_file_name(&session_name_to_resurrect); + let raw_layout = match std::fs::read_to_string(&layout_file_name) { + Ok(raw_layout) => raw_layout, + Err(_e) => { + return Ok(None); + }, + }; + match Layout::from_kdl( + &raw_layout, + Some(layout_file_name.display().to_string()), + None, + None, + ) { + Ok(layout) => Ok(Some(layout)), + Err(e) => { + log::error!( + "Failed to parse resurrection layout file {}: {}", + layout_file_name.display(), + e + ); + return Err(format!( + "Failed to parse resurrection layout file {}: {}.", + layout_file_name.display(), + e + )); + }, + } +} + +pub fn assert_session(name: &str) { + match session_exists(name) { + Ok(result) => { + if result { + return; + } else { + println!("No session named {:?} found.", name); + if let Some(sugg) = get_sessions() + .unwrap() + .iter() + .map(|s| s.0.clone()) + .collect::<Vec<_>>() + .suggest(name) + { + println!(" help: Did you mean `{}`?", sugg); + } + } + }, + Err(e) => { + eprintln!("Error occurred: {:?}", e); + }, + }; + process::exit(1); +} + +pub fn assert_dead_session(name: &str, force: bool) { + match session_exists(name) { + Ok(exists) => { + if exists && !force { + println!( + "A session by the name {:?} exists and is active, use --force to delete it.", + name + ) + } else if exists && force { + println!("A session by the name {:?} exists and is active, but will be force killed and deleted.", name); + return; + } else { + return; + } + }, + Err(e) => { + eprintln!("Error occurred: {:?}", e); + }, + }; + process::exit(1); +} + +pub fn validate_session_name(name: &str) -> Result<(), String> { + if name.trim().is_empty() { + return Err( + "Session name cannot be empty. Please provide a specific session name.".to_string(), + ); + } + if name == "." || name == ".." { + return Err(format!("Invalid session name: \"{}\".", name)); + } + if name.contains('/') { + return Err("Session name cannot contain '/'.".to_string()); + } + Ok(()) +} + +pub fn assert_session_ne(name: &str) { + if let Err(e) = validate_session_name(name) { + eprintln!("{}", e); + process::exit(1); + } + + match session_exists(name) { + Ok(result) if !result => { + let resurrectable_sessions = get_resurrectable_session_names(); + if resurrectable_sessions.iter().find(|s| s == &name).is_some() { + println!("Session with name {:?} already exists, but is dead. Use the attach command to resurrect it or, the delete-session command to kill it or specify a different name.", name); + } else { + return + } + } + Ok(_) => println!("Session with name {:?} already exists. Use attach command to connect to it or specify a different name.", name), + Err(e) => eprintln!("Error occurred: {:?}", e), + }; + process::exit(1); +} + +pub fn session_listing_error_message(kind: io::ErrorKind) -> String { + format!( + "Failed to list existing Zellij sessions in the socket directory:\n {}\n\n\ + Reason: {}\n\n\ + This usually means the directory (or one of its parents) is not readable \ + by the current user - for example if $ZELLIJ_SOCKET_DIR or $XDG_RUNTIME_DIR \ + points to a directory you do not own.\n\ + To fix this, set a readable and writable socket directory, eg.:\n \ + ZELLIJ_SOCKET_DIR=/tmp/zellij-$USER zellij", + ZELLIJ_SOCK_DIR.display(), + io::Error::from(kind) + ) +} + +pub fn generate_unique_session_name() -> Option<String> { + let sessions = get_sessions().map(|sessions| { + sessions + .iter() + .map(|s| s.0.clone()) + .collect::<Vec<String>>() + }); + let dead_sessions = get_resurrectable_session_names(); + let sessions = match sessions { + Ok(sessions) => sessions, + Err(kind) => { + eprintln!("{}", session_listing_error_message(kind)); + return None; + }, + }; + + let name = get_name_generator() + .take(1000) + .find(|name| !sessions.contains(name) && !dead_sessions.contains(name)); + + if let Some(name) = name { + return Some(name); + } else { + return None; + } +} + +/// Create a new random name generator +/// +/// Used to provide a memorable handle for a session when users don't specify a session name when the session is +/// created. +/// +/// Uses the list of adjectives and nouns defined below, with the intention of avoiding unfortunate +/// and offensive combinations. Care should be taken when adding or removing to either list due to the birthday paradox/ +/// hash collisions, e.g. with 4096 unique names, the likelihood of a collision in 10 session names is 1%. +pub fn get_name_generator() -> impl Iterator<Item = String> { + names::Generator::new(&ADJECTIVES, &NOUNS, names::Name::Plain) +} + +/// Generates a random human-readable name using curated adjectives and nouns. +/// Returns a single name in the format: AdjectiveNoun (e.g., "BraveRustacean") +pub fn generate_random_name() -> String { + get_name_generator().next().unwrap() +} + +const ADJECTIVES: &[&'static str] = &[ + "adamant", + "adept", + "adventurous", + "arcadian", + "auspicious", + "awesome", + "blossoming", + "brave", + "charming", + "chatty", + "circular", + "considerate", + "cubic", + "curious", + "delighted", + "didactic", + "diligent", + "effulgent", + "erudite", + "excellent", + "exquisite", + "fabulous", + "fascinating", + "friendly", + "glowing", + "gracious", + "gregarious", + "hopeful", + "implacable", + "inventive", + "joyous", + "judicious", + "jumping", + "kind", + "likable", + "loyal", + "lucky", + "marvellous", + "mellifluous", + "nautical", + "oblong", + "outstanding", + "polished", + "polite", + "profound", + "quadratic", + "quiet", + "rectangular", + "remarkable", + "rusty", + "sensible", + "sincere", + "sparkling", + "splendid", + "stellar", + "tenacious", + "tremendous", + "triangular", + "undulating", + "unflappable", + "unique", + "verdant", + "vitreous", + "wise", + "zippy", +]; + +const NOUNS: &[&'static str] = &[ + "aardvark", + "accordion", + "apple", + "apricot", + "bee", + "brachiosaur", + "cactus", + "capsicum", + "clarinet", + "cowbell", + "crab", + "cuckoo", + "cymbal", + "diplodocus", + "donkey", + "drum", + "duck", + "echidna", + "elephant", + "foxglove", + "galaxy", + "glockenspiel", + "goose", + "hill", + "horse", + "iguanadon", + "jellyfish", + "kangaroo", + "lake", + "lemon", + "lemur", + "magpie", + "megalodon", + "mountain", + "mouse", + "muskrat", + "newt", + "oboe", + "ocelot", + "orange", + "panda", + "peach", + "pepper", + "petunia", + "pheasant", + "piano", + "pigeon", + "platypus", + "quasar", + "rhinoceros", + "river", + "rustacean", + "salamander", + "sitar", + "stegosaurus", + "tambourine", + "tiger", + "tomato", + "triceratops", + "ukulele", + "viola", + "weasel", + "xylophone", + "yak", + "zebra", +]; |
