diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-04-17 14:08:48 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-17 14:08:48 +0200 |
| commit | aa29cbaf6631af0afca6aae427a97b3e478393b3 (patch) | |
| tree | 0ee1cab0f7bdc66cf151f38341252701338c1853 | |
| parent | fix(windows): skip modifier re-encoding for Char keys in cast_crossterm_key (... (diff) | |
| download | sl0ppij-aa29cbaf6631af0afca6aae427a97b3e478393b3.tar.gz sl0ppij-aa29cbaf6631af0afca6aae427a97b3e478393b3.tar.xz sl0ppij-aa29cbaf6631af0afca6aae427a97b3e478393b3.zip | |
Some idle CPU and disk/io fixes, new CommandChanged plugin Event (#5063)
* initial implementation
* fix mouse cpu spam
* prevent excess metadata write and prune sessions on startup
* consolidate CommandChanged and PaneCommandChanged
* tests
* rustfmt
* add pr
29 files changed, 1548 insertions, 271 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 276e7ba7..8290e4be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +* fix: idle CPU + disk i/o, CommandChanged plugin Event, GetSessionList plugin command (https://github.com/zellij-org/zellij/pull/5063) ## [0.44.1] - 2026-04-07 * fix: don't display default ports as offline in `share` plugin (https://github.com/zellij-org/zellij/pull/4908) diff --git a/default-plugins/session-manager/src/main.rs b/default-plugins/session-manager/src/main.rs index afade742..b6031011 100644 --- a/default-plugins/session-manager/src/main.rs +++ b/default-plugins/session-manager/src/main.rs @@ -54,6 +54,8 @@ struct State { request_ids: Vec<String>, is_web_client: bool, current_session_last_saved_time: Option<u64>, + is_visible: bool, + refresh_timer_armed: bool, } register_plugin!(State); @@ -76,17 +78,20 @@ impl ZellijPlugin for State { self.active_screen = ActiveScreen::SingleScreen; } self.single_screen_state.is_welcome_screen = self.is_welcome_screen; - if !self.is_welcome_screen { - set_timeout(0.1); // for the current_session_last_saved_time polling - } + self.is_visible = true; subscribe(&[ EventType::ModeUpdate, EventType::SessionUpdate, EventType::Key, EventType::RunCommandResult, EventType::Timer, + EventType::Visible, ]); rename_plugin_pane(get_plugin_ids().plugin_id, "Session Manager"); + self.refresh_session_list(); + if !self.is_welcome_screen { + self.arm_refresh_timer(); + } } fn pipe(&mut self, pipe_message: PipeMessage) -> bool { @@ -119,12 +124,29 @@ impl ZellijPlugin for State { let mut should_render = false; match event { Event::Timer(_) => { + self.refresh_timer_armed = false; + if !self.is_visible { + return false; + } let new_saved_time = current_session_last_saved_time(); if new_saved_time != self.current_session_last_saved_time { self.current_session_last_saved_time = new_saved_time; should_render = true; } - set_timeout(1.0); + if self.refresh_session_list() { + should_render = true; + } + self.arm_refresh_timer(); + }, + Event::Visible(is_visible) => { + let was_visible = self.is_visible; + self.is_visible = is_visible; + if is_visible && !was_visible { + if self.refresh_session_list() { + should_render = true; + } + self.arm_refresh_timer(); + } }, Event::ModeUpdate(mode_info) => { self.colors = Colors::new(mode_info.style.colors); @@ -1171,6 +1193,47 @@ impl State { } self.session_name = Some(new_name.to_owned()); } + fn arm_refresh_timer(&mut self) { + if !self.refresh_timer_armed { + set_timeout(1.0); + self.refresh_timer_armed = true; + } + } + + fn refresh_session_list(&mut self) -> bool { + let snapshot = match get_session_list() { + Ok(snapshot) => snapshot, + Err(_) => return false, + }; + for session_info in &snapshot.live_sessions { + if session_info.is_current_session { + self.new_session_info + .update_layout_list(session_info.available_layouts.clone()); + } + } + self.resurrectable_sessions + .update(snapshot.resurrectable_sessions); + self.update_session_infos(snapshot.live_sessions); + if !self.is_multi_screen { + self.single_screen_state.update_search_term( + &self.sessions.session_ui_infos, + &self.resurrectable_sessions.all_resurrectable_sessions, + ); + let previous_selection = self.single_screen_state.layout_list.selected_layout_index; + let previous_search_term = self + .single_screen_state + .layout_list + .layout_search_term + .clone(); + self.single_screen_state.layout_list = self.new_session_info.get_layout_list_clone(); + self.single_screen_state.layout_list.layout_search_term = previous_search_term; + self.single_screen_state.layout_list.update_search_term(); + self.single_screen_state.layout_list.selected_layout_index = + previous_selection.min(self.single_screen_state.layout_list.max_index()); + } + true + } + fn update_session_infos(&mut self, session_infos: Vec<SessionInfo>) { let session_ui_infos: Vec<SessionUiInfo> = session_infos .iter() diff --git a/default-plugins/session-manager/src/resurrectable_sessions.rs b/default-plugins/session-manager/src/resurrectable_sessions.rs index 3b03d507..a25d8b36 100644 --- a/default-plugins/session-manager/src/resurrectable_sessions.rs +++ b/default-plugins/session-manager/src/resurrectable_sessions.rs @@ -19,7 +19,7 @@ pub struct ResurrectableSessions { impl ResurrectableSessions { pub fn update(&mut self, mut list: Vec<(String, Duration)>) { - list.sort_by(|a, b| a.1.cmp(&b.1)); + list.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); self.all_resurrectable_sessions = list; if self.is_searching { self.update_search_term(); diff --git a/src/commands.rs b/src/commands.rs index a86d1949..03151275 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -81,19 +81,31 @@ pub(crate) fn kill_all_sessions(yes: bool) { } pub(crate) fn delete_all_sessions(yes: bool, force: bool) { + use std::collections::BTreeMap; + use zellij_server::background_jobs::scan_session_list_default_dirs; + let active_sessions: Vec<String> = get_sessions() .unwrap_or_default() .iter() .map(|s| s.0.clone()) .collect(); - let resurrectable_sessions = get_resurrectable_sessions(); + let (_live_sessions, resurrectable_map) = + scan_session_list_default_dirs(&String::new(), &[], &BTreeMap::new()); + let mut resurrectable_sessions: Vec<(String, Duration)> = + resurrectable_map.into_iter().collect(); + if force { + for entry in get_resurrectable_sessions() { + if !resurrectable_sessions.iter().any(|(n, _)| *n == entry.0) { + resurrectable_sessions.push(entry); + } + } + } let dead_sessions: Vec<_> = if force { resurrectable_sessions } else { resurrectable_sessions - .iter() + .into_iter() .filter(|(name, _)| !active_sessions.contains(name)) - .cloned() .collect() }; if !yes { diff --git a/zellij-client/src/input_handler.rs b/zellij-client/src/input_handler.rs index 8b404f7b..8c6c7b28 100644 --- a/zellij-client/src/input_handler.rs +++ b/zellij-client/src/input_handler.rs @@ -320,8 +320,6 @@ impl InputHandler { } } fn handle_mouse_event(&mut self, mouse_event: &MouseEvent) { - // This dispatch handles all of the output(s) to terminal - // pane(s). self.dispatch_action( Action::MouseEvent { event: *mouse_event, diff --git a/zellij-server/src/background_jobs.rs b/zellij-server/src/background_jobs.rs index 5509c5fd..0ea1704d 100644 --- a/zellij-server/src/background_jobs.rs +++ b/zellij-server/src/background_jobs.rs @@ -4,7 +4,7 @@ use zellij_utils::consts::{ VERSION, ZELLIJ_SESSION_INFO_CACHE_DIR, ZELLIJ_SOCK_DIR, }; #[allow(unused_imports)] -use zellij_utils::data::{Event, HttpVerb, SessionInfo, WebServerStatus}; +use zellij_utils::data::{Event, HttpVerb, LayoutInfo, SessionInfo, WebServerStatus}; use zellij_utils::errors::{prelude::*, BackgroundJobContext, ContextType}; use zellij_utils::input::layout::RunPlugin; #[allow(unused_imports)] @@ -23,7 +23,7 @@ use isahc::{config::RedirectPolicy, HttpClient, Request}; use std::collections::{BTreeMap, HashMap}; use std::fs; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, @@ -43,7 +43,6 @@ pub enum BackgroundJob { DisplayPaneError(Vec<PaneId>, String), AnimatePluginLoading(u32), // u32 - plugin_id StopPluginLoadingAnimation(u32), // u32 - plugin_id - ReadAllSessionInfosOnMachine, // u32 - plugin_id ReportSessionInfo(String, SessionInfo), // String - session name ReportPluginList(BTreeMap<PluginId, RunPlugin>), // String - session name ReportLayoutInfo((String, BTreeMap<String, String>)), // BTreeMap<file_name, pane_contents> @@ -86,9 +85,6 @@ impl From<&BackgroundJob> for BackgroundJobContext { BackgroundJob::StopPluginLoadingAnimation(..) => { BackgroundJobContext::StopPluginLoadingAnimation }, - BackgroundJob::ReadAllSessionInfosOnMachine => { - BackgroundJobContext::ReadAllSessionInfosOnMachine - }, BackgroundJob::ReportSessionInfo(..) => BackgroundJobContext::ReportSessionInfo, BackgroundJob::ReportLayoutInfo(..) => BackgroundJobContext::ReportLayoutInfo, BackgroundJob::RunCommand(..) => BackgroundJobContext::RunCommand, @@ -114,11 +110,25 @@ impl From<&BackgroundJob> for BackgroundJobContext { static LONG_FLASH_DURATION_MS: u64 = 1000; static FLASH_DURATION_MS: u64 = 400; // Doherty threshold static PLUGIN_ANIMATION_OFFSET_DURATION_MD: u64 = 500; -static SESSION_READ_DURATION: u64 = 1000; +static SESSION_METADATA_WRITE_INTERVAL_MS: u64 = 1000; +static UPDATE_AND_REPORT_CWDS_INTERVAL_MS: u64 = 1000; static DEFAULT_SERIALIZATION_INTERVAL: u64 = 60000; static REPAINT_DELAY_MS: u64 = 10; static HELP_TEXT_DEBOUNCE_DURATION: u64 = 5000; +#[derive(Clone)] +pub struct SessionScanState { + pub current_session_name: Arc<Mutex<String>>, + pub current_session_info: Arc<Mutex<SessionInfo>>, + pub current_session_plugin_list: Arc<Mutex<BTreeMap<PluginId, RunPlugin>>>, +} + +static SESSION_SCAN_STATE: std::sync::OnceLock<SessionScanState> = std::sync::OnceLock::new(); + +pub fn session_scan_state() -> Option<&'static SessionScanState> { + SESSION_SCAN_STATE.get() +} + #[allow(unused_variables)] // web_server_base_url used only with web_server_capability feature pub(crate) fn background_jobs_main( bus: Bus<BackgroundJob>, @@ -134,6 +144,12 @@ pub(crate) fn background_jobs_main( let current_session_plugin_list: Arc<Mutex<BTreeMap<PluginId, RunPlugin>>> = Arc::new(Mutex::new(BTreeMap::new())); let current_session_layout = Arc::new(Mutex::new((String::new(), BTreeMap::new()))); + + let _ = SESSION_SCAN_STATE.set(SessionScanState { + current_session_name: current_session_name.clone(), + current_session_info: current_session_info.clone(), + current_session_plugin_list: current_session_plugin_list.clone(), + }); let last_serialization_time = Arc::new(Mutex::new(Instant::now())); let serialization_interval = serialization_interval.map(|s| s * 1000); // convert to // milliseconds @@ -151,6 +167,56 @@ pub(crate) fn background_jobs_main( // We needn't do anything with the runtime, but it should exist at this point. let runtime = crate::global_async_runtime::get_tokio_runtime(); + { + let senders = bus.senders.clone(); + let serialization_ms = serialization_interval.unwrap_or(DEFAULT_SERIALIZATION_INTERVAL); + runtime.spawn(async move { + let mut ticker = + tokio::time::interval(std::time::Duration::from_millis(serialization_ms)); + ticker.tick().await; + loop { + ticker.tick().await; + let _ = senders.send_to_screen(ScreenInstruction::SerializeLayoutForResurrection); + } + }); + } + + { + let senders = bus.senders.clone(); + runtime.spawn(async move { + let mut ticker = tokio::time::interval(std::time::Duration::from_millis( + UPDATE_AND_REPORT_CWDS_INTERVAL_MS, + )); + ticker.tick().await; + loop { + ticker.tick().await; + let _ = senders.send_to_pty(PtyInstruction::UpdateAndReportCwds); + } + }); + } + + if !disable_session_metadata { + let current_session_name = current_session_name.clone(); + let current_session_info = current_session_info.clone(); + let current_session_layout = current_session_layout.clone(); + runtime.spawn(async move { + let mut ticker = tokio::time::interval(std::time::Duration::from_millis( + SESSION_METADATA_WRITE_INTERVAL_MS, + )); + ticker.tick().await; + loop { + ticker.tick().await; + let name = current_session_name.lock().unwrap().clone(); + if name.is_empty() { + continue; + } + let info = current_session_info.lock().unwrap().clone(); + let layout = current_session_layout.lock().unwrap().clone(); + write_session_state_to_disk(name, info, layout); + } + }); + } + loop { let (event, mut err_ctx) = bus.recv().with_context(err_context)?; err_ctx.add_call(ContextType::BackgroundJob((&event).into())); @@ -225,84 +291,6 @@ pub(crate) fn background_jobs_main( .senders .send_to_plugin(PluginInstruction::UpdateSessionSaveTime(timestamp_millis)); }, - BackgroundJob::ReadAllSessionInfosOnMachine => { - // this job should only be run once and it keeps track of other sessions (as well - // as this one's) infos (metadata mostly) and sends it to the screen which in turn - // forwards it to plugins and other places it needs to be - if running_jobs.get(&job).is_some() { - continue; - } - running_jobs.insert(job, Instant::now()); - runtime.spawn({ - let senders = bus.senders.clone(); - let current_session_info = current_session_info.clone(); - let current_session_name = current_session_name.clone(); - let current_session_layout = current_session_layout.clone(); - let current_session_plugin_list = current_session_plugin_list.clone(); - let last_serialization_time = last_serialization_time.clone(); - async move { - loop { - let current_session_name = - current_session_name.lock().unwrap().to_string(); - let current_session_info = current_session_info.lock().unwrap().clone(); - let available_layouts = current_session_info.available_layouts.clone(); - let current_session_layout = - current_session_layout.lock().unwrap().clone(); - if !disable_session_metadata { - write_session_state_to_disk( - current_session_name.clone(), - current_session_info, - current_session_layout, - ); - - // Send SavedCurrentSession instruction to plugin thread - let _timestamp_millis = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - as u64; - } - let mut session_infos_on_machine = - read_other_live_session_states(¤t_session_name); - for (session_name, session_info) in session_infos_on_machine.iter_mut() - { - if session_name == ¤t_session_name { - let current_session_plugin_list = - current_session_plugin_list.lock().unwrap().clone(); - session_info.populate_plugin_list(current_session_plugin_list); - // these are not serialized, so must be explicitly added - session_info.available_layouts = available_layouts.clone(); - } - } - let resurrectable_sessions = - find_resurrectable_sessions(&session_infos_on_machine); - let _ = senders.send_to_screen(ScreenInstruction::UpdateSessionInfos( - session_infos_on_machine, - resurrectable_sessions, - )); - let _ = senders.send_to_pty(PtyInstruction::UpdateAndReportCwds); - if last_serialization_time - .lock() - .unwrap() - .elapsed() - .as_millis() - >= serialization_interval - .unwrap_or(DEFAULT_SERIALIZATION_INTERVAL) - .into() - { - let _ = senders.send_to_screen( - ScreenInstruction::SerializeLayoutForResurrection, - ); - *last_serialization_time.lock().unwrap() = Instant::now(); - } - tokio::time::sleep(std::time::Duration::from_millis( - SESSION_READ_DURATION, - )) - .await; - } - } - }); - }, BackgroundJob::RunCommand( plugin_id, client_id, @@ -669,6 +657,13 @@ fn job_already_running( } } +fn file_content_changed(path: &std::path::Path, new_content: &[u8]) -> bool { + match std::fs::read(path) { + Ok(existing) => existing != new_content, + Err(_) => true, + } +} + pub fn write_session_state_to_disk( current_session_name: String, current_session_info: SessionInfo, @@ -676,38 +671,82 @@ pub fn write_session_state_to_disk( ) { let metadata_cache_file_name = session_info_cache_file_name(¤t_session_name); let (current_session_layout, layout_files_to_write) = current_session_layout; - let _wrote_metadata_file = - std::fs::create_dir_all(session_info_folder_for_session(¤t_session_name).as_path()) - .and_then(|_| std::fs::File::create(metadata_cache_file_name)) - .and_then(|mut f| write!(f, "{}", current_session_info.to_string())); + let new_metadata = current_session_info.to_string(); + if file_content_changed(&metadata_cache_file_name, new_metadata.as_bytes()) { + let _wrote_metadata_file = std::fs::create_dir_all( + session_info_folder_for_session(¤t_session_name).as_path(), + ) + .and_then(|_| std::fs::File::create(&metadata_cache_file_name)) + .and_then(|mut f| write!(f, "{}", new_metadata)); + } if !current_session_layout.is_empty() { let layout_cache_file_name = session_layout_cache_file_name(¤t_session_name); - let _wrote_layout_file = std::fs::create_dir_all( - session_info_folder_for_session(¤t_session_name).as_path(), - ) - .and_then(|_| std::fs::File::create(layout_cache_file_name)) - .and_then(|mut f| write!(f, "{}", current_session_layout)) - .and_then(|_| { - let session_info_folder = session_info_folder_for_session(¤t_session_name); - for (external_file_name, external_file_contents) in layout_files_to_write { - std::fs::File::create(session_info_folder.join(external_file_name)) + if file_content_changed(&layout_cache_file_name, current_session_layout.as_bytes()) { + let _wrote_layout_file = std::fs::create_dir_all( + session_info_folder_for_session(¤t_session_name).as_path(), + ) + .and_then(|_| std::fs::File::create(&layout_cache_file_name)) + .and_then(|mut f| write!(f, "{}", current_session_layout)); + } + let session_info_folder = session_info_folder_for_session(¤t_session_name); + for (external_file_name, external_file_contents) in layout_files_to_write { + let external_file_path = session_info_folder.join(&external_file_name); + if file_content_changed(&external_file_path, external_file_contents.as_bytes()) { + std::fs::File::create(&external_file_path) .and_then(|mut f| write!(f, "{}", external_file_contents)) .unwrap_or_else(|e| { log::error!("Failed to write layout metadata file: {:?}", e); }); } - Ok(()) - }); + } + } +} + +pub fn scan_session_list( + current_session_name: &str, + available_layouts: &[LayoutInfo], + current_session_plugin_list: &BTreeMap<PluginId, RunPlugin>, + sock_dir: &Path, + session_info_cache_dir: &Path, +) -> (BTreeMap<String, SessionInfo>, BTreeMap<String, Duration>) { + let mut session_infos_on_machine = + read_other_live_session_states(current_session_name, sock_dir, session_info_cache_dir); + for (name, info) in session_infos_on_machine.iter_mut() { + if name == current_session_name { + info.populate_plugin_list(current_session_plugin_list.clone()); + info.available_layouts = available_layouts.to_vec(); + } } + let resurrectable_sessions = + find_resurrectable_sessions(&session_infos_on_machine, session_info_cache_dir); + (session_infos_on_machine, resurrectable_sessions) +} + +pub fn scan_session_list_default_dirs( + current_session_name: &str, + available_layouts: &[LayoutInfo], + current_session_plugin_list: &BTreeMap<PluginId, RunPlugin>, +) -> (BTreeMap<String, SessionInfo>, BTreeMap<String, Duration>) { + scan_session_list( + current_session_name, + available_layouts, + current_session_plugin_list, + &*ZELLIJ_SOCK_DIR, + &*ZELLIJ_SESSION_INFO_CACHE_DIR, + ) } -fn read_other_live_session_states(current_session_name: &str) -> BTreeMap<String, SessionInfo> { +fn read_other_live_session_states( + current_session_name: &str, + sock_dir: &Path, + session_info_cache_dir: &Path, +) -> BTreeMap<String, SessionInfo> { let mut other_session_names: Vec<(String, Duration)> = vec![]; let mut session_infos_on_machine = BTreeMap::new(); // we do this so that the session infos will be actual and we're // reasonably sure their session is running - if let Ok(files) = fs::read_dir(&*ZELLIJ_SOCK_DIR) { + if let Ok(files) = fs::read_dir(sock_dir) { files.for_each(|file| { if let Ok(file) = file { if let Ok(file_name) = file.file_name().into_string() { @@ -716,7 +755,6 @@ fn read_other_live_session_states(current_session_name: &str) -> BTreeMap<String .ok() .and_then(|f| f.created().ok().or_else(|| f.modified().ok())) .and_then(|d| d.elapsed().ok()) - .map(|d| Duration::from_secs(d.as_secs())) .unwrap_or_default(); other_session_names.push((file_name, creation_time)); } @@ -726,7 +764,9 @@ fn read_other_live_session_states(current_session_name: &str) -> BTreeMap<String } for (session_name, creation_time) in other_session_names { - let session_cache_file_name = session_info_cache_file_name(&session_name); + let session_cache_file_name = session_info_cache_dir + .join(&session_name) + .join("session-metadata.kdl"); if let Ok(raw_session_info) = fs::read_to_string(&session_cache_file_name) { if let Ok(mut session_info) = SessionInfo::from_string(&raw_session_info, ¤t_session_name) @@ -741,8 +781,9 @@ fn read_other_live_session_states(current_session_name: &str) -> BTreeMap<String fn find_resurrectable_sessions( session_infos_on_machine: &BTreeMap<String, SessionInfo>, + session_info_cache_dir: &Path, ) -> BTreeMap<String, Duration> { - match fs::read_dir(&*ZELLIJ_SESSION_INFO_CACHE_DIR) { + match fs::read_dir(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())) @@ -754,7 +795,7 @@ fn find_resurrectable_sessions( // this is not a dead session... return None; } - let layout_file_name = session_layout_cache_file_name(&session_name); + let layout_file_name = folder_name.join("session-layout.kdl"); let ctime = match std::fs::metadata(&layout_file_name) .and_then(|metadata| metadata.created()) { @@ -773,9 +814,7 @@ fn find_resurrectable_sessions( }, }; let elapsed_duration = ctime - .map(|ctime| { - Duration::from_secs(ctime.elapsed().ok().unwrap_or_default().as_secs()) - }) + .and_then(|ctime| ctime.elapsed().ok()) .unwrap_or_default(); Some((session_name, elapsed_duration)) }) @@ -823,3 +862,108 @@ fn query_webserver_via_ipc(web_server_base_url: &str) -> Result<WebServerStatus> Ok(WebServerStatus::Offline) } + +#[cfg(test)] +#[cfg(unix)] +mod tests { + use super::*; + use std::os::unix::net::UnixListener; + use tempfile::tempdir; + use zellij_utils::data::SessionInfo; + + fn make_socket(dir: &std::path::Path, name: &str) -> UnixListener { + UnixListener::bind(dir.join(name)).expect("bind unix socket") + } + + fn write_metadata(info_dir: &std::path::Path, session: &str, info: &SessionInfo) { + let folder = info_dir.join(session); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("session-metadata.kdl"), info.to_string()).unwrap(); + } + + fn write_layout(info_dir: &std::path::Path, session: &str) { + let folder = info_dir.join(session); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("session-layout.kdl"), "layout { }").unwrap(); + } + + #[test] + fn scan_session_list_returns_empty_when_no_peers() { + let sock_dir = tempdir().unwrap(); + let info_dir = tempdir().unwrap(); + let (live, resurrectable) = scan_session_list( + "me", + &[], + &BTreeMap::new(), + sock_dir.path(), + info_dir.path(), + ); + assert!(live.is_empty()); + assert!(resurrectable.is_empty()); + } + + #[test] + fn scan_session_list_finds_peer_from_socket_and_metadata() { + let sock_dir = tempdir().unwrap(); + let info_dir = tempdir().unwrap(); + let peer = "peer-alpha"; + let _listener = make_socket(sock_dir.path(), peer); + write_metadata(info_dir.path(), peer, &SessionInfo::new(peer.to_string())); + + let (live, resurrectable) = scan_session_list( + "me", + &[], + &BTreeMap::new(), + sock_dir.path(), + info_dir.path(), + ); + assert_eq!(live.len(), 1); + assert!(live.contains_key(peer)); + assert!(resurrectable.is_empty()); + } + + #[test] + fn scan_session_list_finds_resurrectable_from_orphan_metadata() { + let sock_dir = tempdir().unwrap(); + let info_dir = tempdir().unwrap(); + write_layout(info_dir.path(), "dead-beta"); + + let (live, resurrectable) = scan_session_list( + "me", + &[], + &BTreeMap::new(), + sock_dir.path(), + info_dir.path(), + ); + assert!(live.is_empty()); + assert_eq!(resurrectable.len(), 1); + assert!(resurrectable.contains_key("dead-beta")); + } + + #[test] + fn scan_session_list_separates_live_from_resurrectable() { + let sock_dir = tempdir().unwrap(); + let info_dir = tempdir().unwrap(); + for name in ["live-a", "live-b", "live-c"] { + let _listener = make_socket(sock_dir.path(), name); + write_metadata(info_dir.path(), name, &SessionInfo::new(name.to_string())); + std::mem::forget(_listener); + } + for name in ["dead-a", "dead-b"] { + write_layout(info_dir.path(), name); + } + + let (live, resurrectable) = scan_session_list( + "me", + &[], + &BTreeMap::new(), + sock_dir.path(), + info_dir.path(), + ); + assert_eq!(live.len(), 3); + assert_eq!(resurrectable.len(), 2); + for name in ["live-a", "live-b", "live-c"] { + assert!(!resurrectable.contains_key(name)); + } + } +} diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs index 5e3cd6a6..1f2b4d14 100644 --- a/zellij-server/src/lib.rs +++ b/zellij-server/src/lib.rs @@ -10,7 +10,7 @@ pub mod output; pub mod panes; pub mod tab; -mod background_jobs; +pub mod background_jobs; mod global_async_runtime; mod logging_pipe; mod pane_groups; @@ -218,12 +218,11 @@ impl SessionConfiguration { pub fn set_client_runtime_configuration(&mut self, client_id: ClientId, client_config: Config) { self.runtime_config.insert(client_id, client_config); } - pub fn get_client_keybinds(&self, client_id: &ClientId) -> Keybinds { + pub fn get_client_keybinds(&self, client_id: &ClientId) -> &Keybinds { self.runtime_config .get(client_id) - .or_else(|| Some(&self.saved_config)) - .map(|c| c.keybinds.clone()) - .unwrap_or_default() + .map(|c| &c.keybinds) + .unwrap_or(&self.saved_config.keybinds) } pub fn get_client_default_input_mode(&self, client_id: &ClientId) -> InputMode { self.runtime_config @@ -339,7 +338,7 @@ impl SessionMetaData { pub fn get_client_keybinds_and_mode( &self, client_id: &ClientId, - ) -> Option<(Keybinds, &InputMode, InputMode)> { + ) -> Option<(&Keybinds, &InputMode, InputMode)> { // (keybinds, current_input_mode, // default_input_mode) let client_keybinds = self.session_configuration.get_client_keybinds(client_id); diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs index 1445435c..c3e5a588 100644 --- a/zellij-server/src/panes/grid.rs +++ b/zellij-server/src/panes/grid.rs @@ -350,6 +350,33 @@ fn subtract_isize_from_usize(u: usize, i: isize) -> usize { } } +pub fn parse_osc7_path(raw: &[u8]) -> Option<std::path::PathBuf> { + let s = std::str::from_utf8(raw).ok()?; + let rest = s.strip_prefix("file://")?; + let slash_idx = rest.find('/')?; + let encoded_path = &rest[slash_idx..]; + let mut out = Vec::with_capacity(encoded_path.len()); + let bytes = encoded_path.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let end = i.checked_add(3)?; + if end > bytes.len() { + return None; + } + let hex = std::str::from_utf8(&bytes[i + 1..end]).ok()?; + let byte = u8::from_str_radix(hex, 16).ok()?; + out.push(byte); + i = end; + } else { + out.push(bytes[i]); + i += 1; + } + } + let decoded = String::from_utf8(out).ok()?; + Some(std::path::PathBuf::from(decoded)) +} + macro_rules! dump_screen { ($lines:expr) => {{ let mut is_first = true; @@ -605,6 +632,7 @@ pub struct Grid { pub focus_event_tracking: bool, pub search_results: SearchResult, pub pending_clipboard_update: Option<String>, + pub pending_osc7_cwd: Option<std::path::PathBuf>, /// Pending desktop notifications: (payload, terminator) /// Payload is the semicolon-joined params after "99", terminator is "\x07" or "\x1b\\" pub pending_desktop_notifications: Vec<(String, String)>, @@ -892,6 +920,7 @@ impl Grid { search_results: Default::default(), sixel_grid, pending_clipboard_update: None, + pending_osc7_cwd: None, pending_desktop_notifications: Vec::new(), ui_component_bytes: None, style, @@ -2324,9 +2353,19 @@ impl Grid { best.map(|(_layer, plugin_id, pattern, matched, ctx)| (plugin_id, pattern, matched, ctx)) } - pub fn set_hover_position(&mut self, new_pos: Option<Position>) { + pub fn set_hover_position(&mut self, new_pos: Option<Position>) -> bool { if self.hover_position == new_pos { - return; + return false; + } + + let has_hover_consumer = self + .plugin_highlights + .values() + .any(|highlights| highlights.iter().any(|(_, c)| c.on_hover)); + + if !has_hover_consumer { + self.hover_position = new_pos; + return false; } // Mark the canonical group containing the old hover row dirty. @@ -2340,6 +2379,7 @@ impl Grid { self.hover_position = new_pos; self.recompute_hover_tooltip(); + true } /// Mark all physical rows belonging to the logical line group that contains @@ -3428,6 +3468,14 @@ impl Perform for Grid { } }, + b"7" => { + if let Some(raw) = params.get(1) { + if let Some(path) = parse_osc7_path(raw) { + self.pending_osc7_cwd = Some(path); + } + } + }, + // Set color index. b"4" => { for chunk in params[1..].chunks(2) { @@ -4799,3 +4847,42 @@ fn is_selection_boundary_character(character: char) -> bool { #[cfg(test)] #[path = "./unit/grid_tests.rs"] mod grid_tests; + +#[cfg(test)] +mod osc7_parser_tests { + use super::parse_osc7_path; + use std::path::PathBuf; + + #[test] + fn osc7_parser_extracts_path() { + let raw = b"file://host/home/user/foo"; + assert_eq!(parse_osc7_path(raw), Some(PathBuf::from("/home/user/foo"))); + } + + #[test] + fn osc7_parser_decodes_percent_encoded_path() { + let raw = b"file://localhost/tmp/with%20space"; + assert_eq!(parse_osc7_path(raw), Some(PathBuf::from("/tmp/with space"))); + } + + #[test] + fn osc7_parser_rejects_missing_scheme() { + assert_eq!(parse_osc7_path(b"/home/user"), None); + } + + #[test] + fn osc7_parser_rejects_missing_path() { + assert_eq!(parse_osc7_path(b"file://host"), None); + } + + #[test] + fn osc7_parser_rejects_truncated_percent_escape() { + assert_eq!(parse_osc7_path(b"file://host/foo%2"), None); + } + + #[test] + fn osc7_parser_rejects_non_utf8_input() { + let raw = &[0xFFu8, 0xFE, 0xFD]; + assert_eq!(parse_osc7_path(raw), None); + } +} diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs index bed9881d..5aeef4f2 100644 --- a/zellij-server/src/panes/terminal_pane.rs +++ b/zellij-server/src/panes/terminal_pane.rs @@ -594,6 +594,10 @@ impl Pane for TerminalPane { self.grid.pending_desktop_notifications.drain(..).collect() } + fn drain_osc7_cwd(&mut self) -> Option<std::path::PathBuf> { + self.grid.pending_osc7_cwd.take() + } + fn start_selection(&mut self, start: &Position, _client_id: ClientId) { self.grid.start_selection(start); self.set_should_render(true); @@ -987,9 +991,12 @@ impl Pane for TerminalPane { self.grid.clear_plugin_highlights(plugin_id); self.set_should_render(true); } - fn set_hover_position(&mut self, position: Option<Position>) { - self.grid.set_hover_position(position); - self.set_should_render(true); + fn set_hover_position(&mut self, position: Option<Position>) -> bool { + let changed = self.grid.set_hover_position(position); + if changed { + self.set_should_render(true); + } + changed } fn cached_hover_tooltip(&self) -> Option<String> { self.grid.cached_hover_tooltip.clone() diff --git a/zellij-server/src/plugins/wasm_bridge.rs b/zellij-server/src/plugins/wasm_bridge.rs index 31d7e74b..86db429d 100644 --- a/zellij-server/src/plugins/wasm_bridge.rs +++ b/zellij-server/src/plugins/wasm_bridge.rs @@ -2151,6 +2151,7 @@ fn check_event_permission( | Event::FailedToWriteConfigToDisk(..) | Event::CommandPaneReRun(..) | Event::CwdChanged(..) + | Event::CommandChanged(..) | Event::AvailableLayoutInfo(..) | Event::PluginConfigurationChanged(..) | Event::HighlightClicked { .. } diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs index 24113d0b..1a941026 100644 --- a/zellij-server/src/plugins/zellij_exports.rs +++ b/zellij-server/src/plugins/zellij_exports.rs @@ -82,9 +82,9 @@ use zellij_utils::{ ProtobufGenerateRandomNameResponse, ProtobufGetFocusedPaneInfoResponse, ProtobufGetLayoutDirResponse, ProtobufGetPaneCwdResponse, ProtobufGetPaneInfoResponse, ProtobufGetPanePidResponse, ProtobufGetPaneRunningCommandResponse, - ProtobufGetSessionEnvironmentVariablesResponse, ProtobufGetTabInfoResponse, - ProtobufHideFloatingPanesResponse, ProtobufNewTabResponse, ProtobufNewTabsResponse, - ProtobufOpenCommandPaneBackgroundResponse, + ProtobufGetSessionEnvironmentVariablesResponse, ProtobufGetSessionListResponse, + ProtobufGetTabInfoResponse, ProtobufHideFloatingPanesResponse, ProtobufNewTabResponse, + ProtobufNewTabsResponse, ProtobufOpenCommandPaneBackgroundResponse, ProtobufOpenCommandPaneFloatingNearPluginResponse, ProtobufOpenCommandPaneFloatingResponse, ProtobufOpenCommandPaneInPlaceOfPaneIdResponse, @@ -124,9 +124,9 @@ macro_rules! apply_action { $env.capabilities.clone(), $env.client_attributes.clone(), $env.default_shell.clone(), - $env.default_layout.clone(), + &$env.default_layout, None, - $env.keybinds.clone(), + &$env.keybinds, $env.default_mode.clone(), None, ) { @@ -438,6 +438,7 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) { }, PluginCommand::WatchFilesystem => watch_filesystem(env), PluginCommand::ListWindowsVolumes => list_windows_volumes(env), + PluginCommand::GetSessionList => get_session_list(env), PluginCommand::DumpSessionLayout { tab_index } => { dump_session_layout(env, tab_index) }, @@ -1449,9 +1450,9 @@ fn run_action(env: &PluginEnv, mut action: Action, context: BTreeMap<String, Str capabilities, client_attributes, default_shell, - default_layout, + &default_layout, None, - keybinds, + &keybinds, default_mode, None, ) { @@ -4079,6 +4080,44 @@ fn get_pane_running_command(env: &PluginEnv, pane_id: PaneId) { write_pane_running_command_response(env, pane_id, response); } +fn get_session_list(env: &PluginEnv) { + use crate::background_jobs::{scan_session_list_default_dirs, session_scan_state}; + use zellij_utils::data::{GetSessionListResponse, SessionListSnapshot}; + + let response = match session_scan_state() { + Some(state) => { + let (session_name, available_layouts, plugin_list) = { + let name = state.current_session_name.lock().unwrap().clone(); + let info = state.current_session_info.lock().unwrap().clone(); + let plugins = state.current_session_plugin_list.lock().unwrap().clone(); + (name, info.available_layouts, plugins) + }; + + let (live_sessions_map, resurrectable_sessions_map) = + scan_session_list_default_dirs(&session_name, &available_layouts, &plugin_list); + + let _ = env + .senders + .send_to_screen(ScreenInstruction::UpdateSessionInfos( + live_sessions_map.clone(), + resurrectable_sessions_map.clone(), + )); + + let snapshot = SessionListSnapshot { + live_sessions: live_sessions_map.into_values().collect(), + resurrectable_sessions: resurrectable_sessions_map.into_iter().collect(), + }; + GetSessionListResponse::Ok(snapshot) + }, + None => GetSessionListResponse::Err("Session-scan state not initialized".to_string()), + }; + + let protobuf_response = ProtobufGetSessionListResponse::from(response); + wasi_write_object(env, &protobuf_response.encode_to_vec()) + .with_context(|| "failed to write get_session_list response".to_string()) + .non_fatal(); +} + fn send_get_pane_running_command_request( env: &PluginEnv, pane_id: PaneId, @@ -4446,9 +4485,9 @@ fn try_edit_layout( env.capabilities.clone(), env.client_attributes.clone(), env.default_shell.clone(), - env.default_layout.clone(), + &env.default_layout, None, - env.keybinds.clone(), + &env.keybinds, env.default_mode.clone(), None, ) @@ -5311,7 +5350,8 @@ fn check_command_permission( | PluginCommand::SaveSession | PluginCommand::CurrentSessionLastSavedTime | PluginCommand::GetPaneInfo(..) - | PluginCommand::GetTabInfo(..) => PermissionType::ReadApplicationState, + | PluginCommand::GetTabInfo(..) + | PluginCommand::GetSessionList => PermissionType::ReadApplicationState, PluginCommand::RebindKeys { .. } | PluginCommand::Reconfigure(..) => { PermissionType::Reconfigure }, diff --git a/zellij-server/src/pty.rs b/zellij-server/src/pty.rs index 0fdcddc4..478a60e3 100644 --- a/zellij-server/src/pty.rs +++ b/zellij-server/src/pty.rs @@ -154,6 +154,7 @@ pub enum PtyInstruction { response_channel: crossbeam::channel::Sender<GetPaneCwdResponse>, }, UpdateAndReportCwds, + NotifyCwdFromOsc7(u32, PathBuf), Exit, } @@ -186,6 +187,7 @@ impl From<&PtyInstruction> for PtyContext { PtyInstruction::GetPaneRunningCommand { .. } => PtyContext::GetPaneRunningCommand, PtyInstruction::GetPaneCwd { .. } => PtyContext::GetPaneCwd, PtyInstruction::UpdateAndReportCwds => PtyContext::UpdateAndReportCwds, + PtyInstruction::NotifyCwdFromOsc7(..) => PtyContext::NotifyCwdFromOsc7, PtyInstruction::Exit => PtyContext::Exit, } } @@ -202,6 +204,9 @@ pub(crate) struct Pty { post_command_discovery_hook: Option<String>, plugin_cwds: HashMap<u32, PathBuf>, // plugin_id -> cwd terminal_cwds: HashMap<u32, PathBuf>, // terminal_id -> cwd + pane_activity_flags: HashMap<u32, std::sync::Arc<std::sync::atomic::AtomicBool>>, + terminal_cmds: HashMap<u32, Vec<String>>, + terminal_foreground_cmds: HashMap<u32, Vec<String>>, } pub(crate) fn pty_thread_main(mut pty: Pty, layout: Box<Layout>) -> Result<()> { @@ -890,6 +895,9 @@ pub(crate) fn pty_thread_main(mut pty: Pty, layout: Box<Layout>) -> Result<()> { PtyInstruction::UpdateAndReportCwds => { pty.update_and_report_cwds(); }, + PtyInstruction::NotifyCwdFromOsc7(terminal_id, path) => { + pty.notify_cwd_from_osc7(terminal_id, path); + }, PtyInstruction::Exit => break, } } @@ -914,6 +922,9 @@ impl Pty { post_command_discovery_hook, plugin_cwds: HashMap::new(), terminal_cwds: HashMap::new(), + pane_activity_flags: HashMap::new(), + terminal_cmds: HashMap::new(), + terminal_foreground_cmds: HashMap::new(), } } pub fn get_default_terminal( @@ -1118,13 +1129,15 @@ impl Pty { os_input.spawn_terminal(terminal_action, quit_cb, self.default_editor.clone()) }) .with_context(err_context)?; + let activity_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let terminal_bytes = async_runtime().spawn({ let err_context = |terminal_id: u32| format!("failed to run async task for terminal {terminal_id}"); let senders = self.bus.senders.clone(); let debug_to_file = self.debug_to_file; + let activity_flag = activity_flag.clone(); async move { - TerminalBytes::new(terminal_id, reader, senders, debug_to_file) + TerminalBytes::new(terminal_id, reader, senders, debug_to_file, activity_flag) .listen() .await .with_context(|| err_context(terminal_id)) @@ -1133,6 +1146,7 @@ impl Pty { }); self.task_handles.insert(terminal_id, terminal_bytes); + self.pane_activity_flags.insert(terminal_id, activity_flag); if let Some(child_pid) = child_pid { self.id_to_child_pid.insert(terminal_id, child_pid); self.capture_initial_cwd(terminal_id, child_pid); @@ -1318,18 +1332,28 @@ impl Pty { } match reader_result { Ok(reader) => { + let activity_flag = + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let terminal_bytes = async_runtime().spawn({ let senders = self.bus.senders.clone(); let debug_to_file = self.debug_to_file; + let activity_flag = activity_flag.clone(); async move { - TerminalBytes::new(terminal_id, reader, senders, debug_to_file) - .listen() - .await - .context("failed to spawn terminals for layout") - .fatal(); + TerminalBytes::new( + terminal_id, + reader, + senders, + debug_to_file, + activity_flag, + ) + .listen() + .await + .context("failed to spawn terminals for layout") + .fatal(); } }); self.task_handles.insert(terminal_id, terminal_bytes); + self.pane_activity_flags.insert(terminal_id, activity_flag); }, _ => match run_command { Some(run_command) => { @@ -1486,18 +1510,28 @@ impl Pty { } match reader_result { Ok(reader) => { + let activity_flag = + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let terminal_bytes = async_runtime().spawn({ let senders = self.bus.senders.clone(); let debug_to_file = self.debug_to_file; + let activity_flag = activity_flag.clone(); async move { - TerminalBytes::new(terminal_id, reader, senders, debug_to_file) - .listen() - .await - .context("failed to spawn terminals for layout") - .fatal(); + TerminalBytes::new( + terminal_id, + reader, + senders, + debug_to_file, + activity_flag, + ) + .listen() + .await + .context("failed to spawn terminals for layout") + .fatal(); } }); self.task_handles.insert(terminal_id, terminal_bytes); + self.pane_activity_flags.insert(terminal_id, activity_flag); }, _ => match run_command { Some(run_command) => { @@ -1779,6 +1813,10 @@ impl Pty { .with_context(err_context) .non_fatal(); } + self.pane_activity_flags.remove(&id); + self.terminal_cwds.remove(&id); + self.terminal_cmds.remove(&id); + self.terminal_foreground_cmds.remove(&id); self.bus .os_input .as_ref() @@ -1865,13 +1903,15 @@ impl Pty { os_input.re_run_command_in_terminal(id, run_command, quit_cb) }) .with_context(err_context)?; + let activity_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let terminal_bytes = async_runtime().spawn({ let err_context = |pane_id| format!("failed to run async task for pane {pane_id:?}"); let senders = self.bus.senders.clone(); let debug_to_file = self.debug_to_file; + let activity_flag = activity_flag.clone(); async move { - TerminalBytes::new(id, reader, senders, debug_to_file) + TerminalBytes::new(id, reader, senders, debug_to_file, activity_flag) .listen() .await .with_context(|| err_context(pane_id)) @@ -1880,6 +1920,7 @@ impl Pty { }); self.task_handles.insert(id, terminal_bytes); + self.pane_activity_flags.insert(id, activity_flag); if let Some(child_pid) = child_pid { self.id_to_child_pid.insert(id, child_pid); self.capture_initial_cwd(id, child_pid); @@ -2023,28 +2064,44 @@ impl Pty { } pub fn update_and_report_cwds(&mut self) { - let terminal_ids: Vec<u32> = self.id_to_child_pid.keys().copied().collect(); + use std::sync::atomic::Ordering; - let pids: Vec<_> = terminal_ids + let active_terminal_ids: Vec<u32> = self + .id_to_child_pid + .keys() + .copied() + .filter(|id| { + self.pane_activity_flags + .get(id) + .map(|f| f.swap(false, Ordering::Relaxed)) + .unwrap_or(false) + }) + .collect(); + + if active_terminal_ids.is_empty() { + return; + } + + let pids: Vec<_> = active_terminal_ids .iter() - .filter_map(|id| self.id_to_child_pid.get(&id)) + .filter_map(|id| self.id_to_child_pid.get(id)) .copied() .collect(); - let (pids_to_cwds, _) = self + let (pids_to_cwds, pids_to_cmds) = self .bus .os_input .as_ref() .map(|os_input| os_input.get_cwds(pids)) .unwrap_or_default(); - for terminal_id in terminal_ids { - let process_id = self.id_to_child_pid.get(&terminal_id); + for terminal_id in &active_terminal_ids { + let process_id = self.id_to_child_pid.get(terminal_id); let cwd = process_id.and_then(|pid| pids_to_cwds.get(pid)); if let Some(cwd) = cwd { - if self.terminal_cwds.get(&terminal_id) != Some(cwd) { - let pane_id = PaneId::Terminal(terminal_id); + if self.terminal_cwds.get(terminal_id) != Some(cwd) { + let pane_id = PaneId::Terminal(*terminal_id); let focused_client_ids: Vec<ClientId> = self .active_panes .iter() @@ -2060,7 +2117,62 @@ impl Pty { Event::CwdChanged(pane_id.into(), cwd.clone(), focused_client_ids), )])); } - self.terminal_cwds.insert(terminal_id, cwd.clone()); + self.terminal_cwds.insert(*terminal_id, cwd.clone()); + } + + let cmd = process_id.and_then(|pid| pids_to_cmds.get(pid)); + if let Some(cmd) = cmd { + self.terminal_cmds.insert(*terminal_id, cmd.clone()); + } + } + + let ppids_to_cmds = self + .bus + .os_input + .as_ref() + .map(|os_input| os_input.get_all_cmds_by_ppid(&self.post_command_discovery_hook)) + .unwrap_or_default(); + + for terminal_id in &active_terminal_ids { + let process_id = self.id_to_child_pid.get(terminal_id); + let foreground_cmd: Vec<String> = process_id + .and_then(|pid| ppids_to_cmds.get(&pid.to_string())) + .cloned() + .unwrap_or_default(); + + if self.terminal_foreground_cmds.get(terminal_id) != Some(&foreground_cmd) { + let pane_id = PaneId::Terminal(*terminal_id); + let focused_client_ids: Vec<ClientId> = self + .active_panes + .iter() + .filter(|(_, active_pane)| *active_pane == &pane_id) + .map(|(client_id, _)| *client_id) + .collect(); + let (command, is_foreground) = if foreground_cmd.is_empty() { + let shell_cmd = self + .terminal_cmds + .get(terminal_id) + .cloned() + .unwrap_or_default(); + (shell_cmd, false) + } else { + (foreground_cmd.clone(), true) + }; + let _ = self + .bus + .senders + .send_to_plugin(PluginInstruction::Update(vec![( + None, + None, + Event::CommandChanged( + pane_id.into(), + command, + is_foreground, + focused_client_ids, + ), + )])); + self.terminal_foreground_cmds + .insert(*terminal_id, foreground_cmd); } } } @@ -2074,6 +2186,32 @@ impl Pty { self.post_command_discovery_hook = post_command_discovery_hook; } + pub fn notify_cwd_from_osc7(&mut self, terminal_id: u32, path: PathBuf) { + use std::sync::atomic::Ordering; + + if self.terminal_cwds.get(&terminal_id) != Some(&path) { + let pane_id = PaneId::Terminal(terminal_id); + let focused_client_ids: Vec<ClientId> = self + .active_panes + .iter() + .filter(|(_, active_pane)| *active_pane == &pane_id) + .map(|(client_id, _)| *client_id) + .collect(); + let _ = self + .bus + .senders + .send_to_plugin(PluginInstruction::Update(vec![( + None, + None, + Event::CwdChanged(pane_id.into(), path.clone(), focused_client_ids), + )])); + self.terminal_cwds.insert(terminal_id, path); + } + if let Some(flag) = self.pane_activity_flags.get(&terminal_id) { + flag.store(false, Ordering::Relaxed); + } + } + pub fn send_sigint_to_pane(&self, pane_id: PaneId) { let err_context = || format!("failed to send SIGINT to pane {:?}", pane_id); @@ -2263,3 +2401,7 @@ pub fn get_default_shell() -> PathBuf { "cmd.exe".to_string() })) } + +#[cfg(test)] +#[path = "./unit/pty_tests.rs"] +mod pty_tests; diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs index b060511a..155997f9 100644 --- a/zellij-server/src/route.rs +++ b/zellij-server/src/route.rs @@ -203,9 +203,9 @@ pub(crate) fn route_action( capabilities: PluginCapabilities, client_attributes: ClientAttributes, default_shell: Option<TerminalAction>, - default_layout: Box<Layout>, + default_layout: &Layout, mut seen_cli_pipes: Option<&mut HashSet<String>>, - client_keybinds: Keybinds, + client_keybinds: &Keybinds, default_mode: InputMode, os_input: Option<Box<dyn ServerOsApi>>, ) -> Result<(bool, Option<ActionCompletionResult>)> { @@ -1517,7 +1517,7 @@ pub(crate) fn route_action( Action::BreakPane => { senders .send_to_screen(ScreenInstruction::BreakPane( - default_layout.clone(), + Box::new(default_layout.clone()), default_shell.clone(), client_id, Some(NotificationEnd::new(completion_tx)), @@ -2230,27 +2230,22 @@ pub(crate) fn route_thread_main( .unwrap() .set_last_active_client(client_id); - let session_data_assets = - session_data.read().as_ref().unwrap().as_ref().map(|s| { - ( - s.senders.clone(), - s.capabilities.clone(), - s.client_attributes.clone(), - s.default_shell.clone(), - s.layout.clone(), - s.session_configuration - .get_client_configuration(&client_id) - .options - .default_mode - .unwrap_or(InputMode::Normal) - .clone(), - ) - }); - if let Some((keybinds, input_mode, default_input_mode)) = session_data - .read() - .unwrap() - .as_ref() - .and_then(|s| s.get_client_keybinds_and_mode(&client_id)) + let session_data_guard = session_data.read().unwrap(); + let session_data_assets = session_data_guard.as_ref().map(|s| { + ( + s.senders.clone(), + s.capabilities.clone(), + s.client_attributes.clone(), + s.default_shell.clone(), + &*s.layout, + s.session_configuration + .get_client_default_input_mode(&client_id), + ) + }); + if let Some((keybinds, input_mode, default_input_mode)) = + session_data_guard + .as_ref() + .and_then(|s| s.get_client_keybinds_and_mode(&client_id)) { if let Some(( senders, @@ -2288,9 +2283,9 @@ pub(crate) fn route_thread_main( capabilities, client_attributes.clone(), default_shell.clone(), - layout.clone(), + layout, Some(&mut seen_cli_pipes), - keybinds.clone(), + keybinds, client_input_mode, Some(os_input.clone()), ) { @@ -2345,25 +2340,19 @@ pub(crate) fn route_thread_main( }); } - let session_data_assets = - session_data.read().unwrap().as_ref().map(|s| { - ( - s.senders.clone(), - s.capabilities.clone(), - s.client_attributes.clone(), - s.default_shell.clone(), - s.layout.clone(), - s.session_configuration - .get_client_configuration(&client_id) - .options - .default_mode - .unwrap_or(InputMode::Normal) - .clone(), - s.session_configuration - .get_client_keybinds(&client_id) - .clone(), - ) - }); + let session_data_guard = session_data.read().unwrap(); + let session_data_assets = session_data_guard.as_ref().map(|s| { + ( + s.senders.clone(), + s.capabilities.clone(), + s.client_attributes.clone(), + s.default_shell.clone(), + &*s.layout, + s.session_configuration + .get_client_default_input_mode(&client_id), + s.session_configuration.get_client_keybinds(&client_id), + ) + }); if let Some(( senders, capabilities, diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index 81ee975b..f05b0823 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -50,7 +50,7 @@ use zellij_utils::errors::prelude::*; use zellij_utils::input::command::RunCommand; use zellij_utils::input::config::Config; use zellij_utils::input::keybinds::Keybinds; -use zellij_utils::input::mouse::MouseEvent; +use zellij_utils::input::mouse::{MouseEvent, MouseEventType}; use zellij_utils::input::options::Clipboard; use zellij_utils::ipc::{ExitReason, ServerToClientMsg}; use zellij_utils::pane_size::{PaneGeom, Size, SizeInPixels}; @@ -1320,10 +1320,10 @@ pub(crate) struct Screen { copy_options: CopyOptions, debug: bool, session_name: String, - session_infos_on_machine: BTreeMap<String, SessionInfo>, // String is the session name, can + peer_sessions_cache: BTreeMap<String, SessionInfo>, // String is the session name, can // also be this session - resurrectable_sessions: BTreeMap<String, Duration>, // String is the session name, duration is - // its creation time + resurrectable_sessions_cache: BTreeMap<String, Duration>, // String is the session name, + // duration is its creation time default_layout: Box<Layout>, default_shell: PathBuf, styled_underlines: bool, @@ -1395,9 +1395,9 @@ impl Screen { ) -> Self { let session_name = mode_info.session_name.clone().unwrap_or_default(); let session_info = SessionInfo::new(session_name.clone()); - let mut session_infos_on_machine = BTreeMap::new(); - let resurrectable_sessions = BTreeMap::new(); - session_infos_on_machine.insert(session_name.clone(), session_info); + let mut peer_sessions_cache = BTreeMap::new(); + let resurrectable_sessions_cache = BTreeMap::new(); + peer_sessions_cache.insert(session_name.clone(), session_info); let current_pane_group = PaneGroups::new(bus.senders.clone()); Screen { bus, @@ -1424,7 +1424,7 @@ impl Screen { copy_options, debug, session_name, - session_infos_on_machine, + peer_sessions_cache, default_layout, default_layout_name, default_shell, @@ -1434,7 +1434,7 @@ impl Screen { styled_underlines, osc8_hyperlinks, arrow_fonts, - resurrectable_sessions, + resurrectable_sessions_cache, layout_dir, explicitly_disable_kitty_keyboard_protocol, default_editor, @@ -2939,16 +2939,31 @@ impl Screen { .senders .send_to_background_jobs(BackgroundJob::ReportSessionInfo( self.session_name.to_owned(), - session_info, + session_info.clone(), )) .with_context(err_context)?; + self.peer_sessions_cache + .insert(self.session_name.clone(), session_info); + let mut live_sessions: Vec<SessionInfo> = + self.peer_sessions_cache.values().cloned().collect(); + for info in live_sessions.iter_mut() { + info.is_current_session = info.name == self.session_name; + } + let resurrectable_sessions: Vec<(String, Duration)> = self + .resurrectable_sessions_cache + .iter() + .map(|(n, d)| (n.clone(), *d)) + .collect(); self.bus .senders - .send_to_background_jobs(BackgroundJob::ReadAllSessionInfosOnMachine) + .send_to_plugin(PluginInstruction::Update(vec![( + None, + None, + Event::SessionUpdate(live_sessions, resurrectable_sessions), + )])) .with_context(err_context)?; - // TODO: consider moving this elsewhere self.bus .senders .send_to_background_jobs(BackgroundJob::QueryZellijWebServerStatus) @@ -2971,16 +2986,16 @@ impl Screen { new_session_infos: BTreeMap<String, SessionInfo>, resurrectable_sessions: BTreeMap<String, Duration>, ) -> Result<()> { - self.session_infos_on_machine = new_session_infos; - self.resurrectable_sessions = resurrectable_sessions; + self.peer_sessions_cache = new_session_infos; + self.resurrectable_sessions_cache = resurrectable_sessions; self.bus .senders .send_to_plugin(PluginInstruction::Update(vec![( None, None, Event::SessionUpdate( - self.session_infos_on_machine.values().cloned().collect(), - self.resurrectable_sessions + self.peer_sessions_cache.values().cloned().collect(), + self.resurrectable_sessions_cache .iter() .map(|(n, c)| (n.clone(), c.clone())) .collect(), @@ -4215,6 +4230,12 @@ impl Screen { } } pub fn handle_mouse_event(&mut self, event: MouseEvent, client_id: ClientId) { + let is_bare_motion = event.event_type == MouseEventType::Motion + && !event.left + && !event.right + && !event.middle + && !event.wheel_up + && !event.wheel_down; match self .get_active_tab_mut(client_id) .and_then(|tab| tab.handle_mouse_event(&event, client_id)) @@ -4240,10 +4261,12 @@ impl Screen { } } if mouse_effect.state_changed { - let _ = self.log_and_report_session_state(); + if !is_bare_motion { + let _ = self.log_and_report_session_state(); + } should_render = true; } - if !mouse_effect.leave_clipboard_message { + if !mouse_effect.leave_clipboard_message && !is_bare_motion { let target_plugin_ids = self.targeted_plugin_ids(client_id, EventType::InputReceived); let plugin_updates: Vec<_> = target_plugin_ids @@ -4256,7 +4279,7 @@ impl Screen { .senders .send_to_plugin(PluginInstruction::Update(plugin_updates)); } - should_render = true; // TODO: do we need this? + should_render = true; } if should_render { self.render(None).non_fatal(); @@ -7923,7 +7946,7 @@ pub(crate) fn screen_thread_main( _completion_tx, // the action ends here, dropping this will release anything // waiting for it ) => { - if screen.session_infos_on_machine.contains_key(&name) { + if screen.peer_sessions_cache.contains_key(&name) { let error_text = "A session by this name already exists."; log::error!("{}", error_text); if let Some(os_input) = &mut screen.bus.os_input { @@ -7934,7 +7957,7 @@ pub(crate) fn screen_thread_main( }, ); } - } else if screen.resurrectable_sessions.contains_key(&name) { + } else if screen.resurrectable_sessions_cache.contains_key(&name) { let error_text = "A resurrectable session by this name exists, cannot use this name."; log::error!("{}", error_text); diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index 0eb953c7..928f7c79 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -415,6 +415,9 @@ pub trait Pane { fn drain_desktop_notifications(&mut self) -> Vec<(String, String)> { vec![] } + fn drain_osc7_cwd(&mut self) -> Option<std::path::PathBuf> { + None + } fn render_full_viewport(&mut self) {} fn relative_position(&self, position_on_screen: &Position) -> Position { position_on_screen.relative_to(self.get_content_y(), self.get_content_x()) @@ -634,7 +637,9 @@ pub trait Pane { ) { } fn clear_plugin_highlights(&mut self, _plugin_id: u32) {} - fn set_hover_position(&mut self, _position: Option<Position>) {} + fn set_hover_position(&mut self, _position: Option<Position>) -> bool { + false + } fn cached_hover_tooltip(&self) -> Option<String> { None } @@ -2720,6 +2725,7 @@ impl Tab { let messages_to_pty = terminal_output.drain_messages_to_pty(); let clipboard_update = terminal_output.drain_clipboard_update(); let desktop_notifications = terminal_output.drain_desktop_notifications(); + let osc7_cwd = terminal_output.drain_osc7_cwd(); for message in messages_to_pty { self.write_to_pane_id_without_preprocessing(message, PaneId::Terminal(pid)) .with_context(err_context)?; @@ -2732,6 +2738,11 @@ impl Tab { self.forward_desktop_notifications(desktop_notifications, pid) .with_context(err_context)?; } + if let Some(path) = osc7_cwd { + let _ = self + .senders + .send_to_pty(PtyInstruction::NotifyCwdFromOsc7(pid, path)); + } } Ok(()) } diff --git a/zellij-server/src/tab/mouse_handler.rs b/zellij-server/src/tab/mouse_handler.rs index b14cbfca..97f4ce27 100644 --- a/zellij-server/src/tab/mouse_handler.rs +++ b/zellij-server/src/tab/mouse_handler.rs @@ -651,7 +651,13 @@ impl MouseHandler { let err_context = || format!("failed to execute mouse action {action:?} for client {client_id}"); - if !matches!(action, MouseAction::UpdateHover { .. }) { + let preserves_help_text = matches!(&action, MouseAction::UpdateHover { .. }) + || matches!( + &action, + MouseAction::SendToTerminal { event, .. } + if event.event_type == MouseEventType::Motion + ); + if !preserves_help_text { tab.mouse_help_text_visible.remove(&client_id); } @@ -1154,8 +1160,9 @@ impl MouseHandler { if let Some(pane) = tab.get_pane_with_id_mut(pane_id) { if !pane.terminal_emulator_wants_mouse() { let relative = pane.relative_position(&event.position); - pane.set_hover_position(Some(relative)); - should_render = true; + if pane.set_hover_position(Some(relative)) { + should_render = true; + } } } } diff --git a/zellij-server/src/terminal_bytes.rs b/zellij-server/src/terminal_bytes.rs index 809163a5..0fcdc4ce 100644 --- a/zellij-server/src/terminal_bytes.rs +++ b/zellij-server/src/terminal_bytes.rs @@ -1,4 +1,8 @@ use crate::{os_input_output::AsyncReader, screen::ScreenInstruction, thread_bus::ThreadSenders}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; use std::time::{Duration, Instant}; use tokio::task; use zellij_utils::{ @@ -11,6 +15,7 @@ pub(crate) struct TerminalBytes { senders: ThreadSenders, async_reader: Box<dyn AsyncReader>, debug: bool, + activity_flag: Arc<AtomicBool>, } impl TerminalBytes { @@ -19,12 +24,14 @@ impl TerminalBytes { async_reader: Box<dyn AsyncReader>, senders: ThreadSenders, debug: bool, + activity_flag: Arc<AtomicBool>, ) -> Self { TerminalBytes { terminal_id, senders, debug, async_reader, + activity_flag, } } pub async fn listen(&mut self) -> Result<()> { @@ -52,6 +59,7 @@ impl TerminalBytes { break; }, Ok(n_bytes) => { + self.activity_flag.store(true, Ordering::Relaxed); let bytes = &buf[..n_bytes]; if self.debug { let _ = debug_to_file(bytes, self.terminal_id as i32); diff --git a/zellij-server/src/unit/pty_tests.rs b/zellij-server/src/unit/pty_tests.rs new file mode 100644 index 00000000..1080b6ca --- /dev/null +++ b/zellij-server/src/unit/pty_tests.rs @@ -0,0 +1,479 @@ +use super::*; +use crate::os_input_output::ServerOsApi; +use crate::plugins::PluginInstruction; +use crate::thread_bus::Bus; +use interprocess::local_socket::Stream as LocalSocketStream; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use zellij_utils::channels::{self, SenderWithContext}; +use zellij_utils::data::{Event, Palette}; +use zellij_utils::errors::ErrorContext; +use zellij_utils::input::command::RunCommand; +use zellij_utils::ipc::{ClientToServerMsg, IpcReceiverWithContext, ServerToClientMsg}; + +#[derive(Clone)] +struct MockOsApi { + cwds: Arc<Mutex<HashMap<u32, PathBuf>>>, + cmds: Arc<Mutex<HashMap<u32, Vec<String>>>>, + cmds_by_ppid: Arc<Mutex<HashMap<String, Vec<String>>>>, +} + +impl MockOsApi { + fn new() -> Self { + MockOsApi { + cwds: Arc::new(Mutex::new(HashMap::new())), + cmds: Arc::new(Mutex::new(HashMap::new())), + cmds_by_ppid: Arc::new(Mutex::new(HashMap::new())), + } + } + fn set_cwd(&self, pid: u32, path: PathBuf) { + self.cwds.lock().unwrap().insert(pid, path); + } + fn set_cmd(&self, pid: u32, cmd: Vec<String>) { + self.cmds.lock().unwrap().insert(pid, cmd); + } + fn set_foreground_cmd(&self, ppid: u32, cmd: Vec<String>) { + self.cmds_by_ppid + .lock() + .unwrap() + .insert(ppid.to_string(), cmd); + } + fn clear_foreground_cmd(&self, ppid: u32) { + self.cmds_by_ppid.lock().unwrap().remove(&ppid.to_string()); + } +} + +impl ServerOsApi for MockOsApi { + fn set_terminal_size_using_terminal_id( + &self, + _: u32, + _: u16, + _: u16, + _: Option<u16>, + _: Option<u16>, + ) -> anyhow::Result<()> { + Ok(()) + } + fn spawn_terminal( + &self, + _: TerminalAction, + _: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>, + _: Option<PathBuf>, + ) -> anyhow::Result<(u32, Box<dyn AsyncReader>, Option<u32>)> { + unimplemented!() + } + fn write_to_tty_stdin(&self, _: u32, buf: &[u8]) -> anyhow::Result<usize> { + Ok(buf.len()) + } + fn tcdrain(&self, _: u32) -> anyhow::Result<()> { + Ok(()) + } + fn kill(&self, _: u32) -> anyhow::Result<()> { + Ok(()) + } + fn force_kill(&self, _: u32) -> anyhow::Result<()> { + Ok(()) + } + fn send_sigint(&self, _: u32) -> anyhow::Result<()> { + Ok(()) + } + fn box_clone(&self) -> Box<dyn ServerOsApi> { + Box::new((*self).clone()) + } + fn send_to_client(&self, _: ClientId, _: ServerToClientMsg) -> anyhow::Result<()> { + Ok(()) + } + fn new_client( + &mut self, + _: ClientId, + _: LocalSocketStream, + ) -> anyhow::Result<IpcReceiverWithContext<ClientToServerMsg>> { + unimplemented!() + } + fn new_client_with_reply( + &mut self, + _: ClientId, + _: LocalSocketStream, + _: LocalSocketStream, + ) -> anyhow::Result<IpcReceiverWithContext<ClientToServerMsg>> { + unimplemented!() + } + fn remove_client(&mut self, _: ClientId) -> anyhow::Result<()> { + Ok(()) + } + fn load_palette(&self) -> Palette { + Palette::default() + } + fn get_cwd(&self, pid: u32) -> Option<PathBuf> { + self.cwds.lock().unwrap().get(&pid).cloned() + } + fn get_cwds(&self, pids: Vec<u32>) -> (HashMap<u32, PathBuf>, HashMap<u32, Vec<String>>) { + let cwds_lock = self.cwds.lock().unwrap(); + let cmds_lock = self.cmds.lock().unwrap(); + let cwds = pids + .iter() + .filter_map(|pid| cwds_lock.get(pid).map(|cwd| (*pid, cwd.clone()))) + .collect(); + let cmds = pids + .iter() + .filter_map(|pid| cmds_lock.get(pid).map(|cmd| (*pid, cmd.clone()))) + .collect(); + (cwds, cmds) + } + fn get_all_cmds_by_ppid(&self, _: &Option<String>) -> HashMap<String, Vec<String>> { + self.cmds_by_ppid.lock().unwrap().clone() + } + fn write_to_file(&mut self, _: String, _: Option<String>) -> anyhow::Result<()> { + Ok(()) + } + fn re_run_command_in_terminal( + &self, + _: u32, + _: RunCommand, + _: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>, + ) -> anyhow::Result<(Box<dyn AsyncReader>, Option<u32>)> { + unimplemented!() + } + fn clear_terminal_id(&self, _: u32) -> anyhow::Result<()> { + Ok(()) + } +} + +fn make_pty_with_plugin_receiver( + mock: MockOsApi, +) -> (Pty, channels::Receiver<(PluginInstruction, ErrorContext)>) { + let (plugin_tx, plugin_rx) = channels::unbounded(); + let plugin_sender = SenderWithContext::new(plugin_tx); + let mut bus: Bus<PtyInstruction> = Bus::empty().should_silently_fail(); + bus.os_input = Some(Box::new(mock)); + bus.senders.to_plugin = Some(plugin_sender); + let pty = Pty::new(bus, false, None, None); + (pty, plugin_rx) +} + +fn set_active_terminal(pty: &mut Pty, terminal_id: u32, child_pid: u32) { + let flag = Arc::new(AtomicBool::new(true)); + pty.id_to_child_pid.insert(terminal_id, child_pid); + pty.pane_activity_flags.insert(terminal_id, flag); +} + +fn collect_cwd_changed_events( + rx: &channels::Receiver<(PluginInstruction, ErrorContext)>, +) -> Vec<(PaneId, PathBuf)> { + let mut events = Vec::new(); + while let Ok((instruction, _)) = rx.try_recv() { + if let PluginInstruction::Update(updates) = instruction { + for (_, _, event) in updates { + if let Event::CwdChanged(pane_id, cwd, _) = event { + events.push((pane_id.into(), cwd)); + } + } + } + } + events +} + +fn collect_command_changed_events( + rx: &channels::Receiver<(PluginInstruction, ErrorContext)>, +) -> Vec<(PaneId, Vec<String>, bool)> { + let mut events = Vec::new(); + while let Ok((instruction, _)) = rx.try_recv() { + if let PluginInstruction::Update(updates) = instruction { + for (_, _, event) in updates { + if let Event::CommandChanged(pane_id, cmd, is_foreground, _) = event { + events.push((pane_id.into(), cmd, is_foreground)); + } + } + } + } + events +} + +#[test] +fn foreground_command_emitted_with_is_foreground_true() { + let mock = MockOsApi::new(); + let child_pid = 100; + mock.set_foreground_cmd(child_pid, vec!["vim".into(), "file.rs".into()]); + let (mut pty, rx) = make_pty_with_plugin_receiver(mock); + set_active_terminal(&mut pty, 1, child_pid); + + pty.update_and_report_cwds(); + + let events = collect_command_changed_events(&rx); + assert_eq!(events.len(), 1); + assert_eq!(events[0].0, PaneId::Terminal(1)); + assert_eq!(events[0].1, vec!["vim", "file.rs"]); + assert!(events[0].2, "expected is_foreground=true"); +} + +#[test] +fn empty_foreground_falls_back_to_shell_command() { + let mock = MockOsApi::new(); + let child_pid = 100; + mock.set_cmd(child_pid, vec!["/bin/bash".into()]); + let (mut pty, rx) = make_pty_with_plugin_receiver(mock); + set_active_terminal(&mut pty, 1, child_pid); + + pty.update_and_report_cwds(); + + let events = collect_command_changed_events(&rx); + assert_eq!(events.len(), 1); + assert_eq!(events[0].0, PaneId::Terminal(1)); + assert_eq!(events[0].1, vec!["/bin/bash"]); + assert!(!events[0].2, "expected is_foreground=false"); +} + +#[test] +fn foreground_clearing_emits_shell_fallback() { + let mock = MockOsApi::new(); + let child_pid = 100; + mock.set_cmd(child_pid, vec!["/bin/zsh".into()]); + mock.set_foreground_cmd(child_pid, vec!["cargo".into(), "build".into()]); + let (mut pty, rx) = make_pty_with_plugin_receiver(mock.clone()); + set_active_terminal(&mut pty, 1, child_pid); + + pty.update_and_report_cwds(); + let events = collect_command_changed_events(&rx); + assert_eq!(events.len(), 1); + assert!(events[0].2, "first event should be foreground"); + assert_eq!(events[0].1, vec!["cargo", "build"]); + + mock.clear_foreground_cmd(child_pid); + pty.pane_activity_flags + .get(&1) + .unwrap() + .store(true, Ordering::Relaxed); + + pty.update_and_report_cwds(); + let events = collect_command_changed_events(&rx); + assert_eq!(events.len(), 1); + assert_eq!(events[0].1, vec!["/bin/zsh"]); + assert!( + !events[0].2, + "after clearing foreground, should fall back to shell" + ); +} + +#[test] +fn no_event_when_foreground_unchanged() { + let mock = MockOsApi::new(); + let child_pid = 100; + mock.set_foreground_cmd(child_pid, vec!["htop".into()]); + let (mut pty, rx) = make_pty_with_plugin_receiver(mock); + set_active_terminal(&mut pty, 1, child_pid); + + pty.update_and_report_cwds(); + let _ = collect_command_changed_events(&rx); + + pty.pane_activity_flags + .get(&1) + .unwrap() + .store(true, Ordering::Relaxed); + pty.update_and_report_cwds(); + let events = collect_command_changed_events(&rx); + assert!( + events.is_empty(), + "no event expected when command unchanged" + ); +} + +#[test] +fn no_event_for_inactive_terminal() { + let mock = MockOsApi::new(); + let child_pid = 100; + mock.set_foreground_cmd(child_pid, vec!["vim".into()]); + let (mut pty, rx) = make_pty_with_plugin_receiver(mock); + set_active_terminal(&mut pty, 1, child_pid); + pty.pane_activity_flags + .get(&1) + .unwrap() + .store(false, Ordering::Relaxed); + + pty.update_and_report_cwds(); + let events = collect_command_changed_events(&rx); + assert!( + events.is_empty(), + "inactive terminal should produce no events" + ); +} + +#[test] +fn foreground_change_between_two_commands() { + let mock = MockOsApi::new(); + let child_pid = 100; + mock.set_foreground_cmd(child_pid, vec!["vim".into()]); + let (mut pty, rx) = make_pty_with_plugin_receiver(mock.clone()); + set_active_terminal(&mut pty, 1, child_pid); + + pty.update_and_report_cwds(); + let events = collect_command_changed_events(&rx); + assert_eq!(events[0].1, vec!["vim"]); + assert!(events[0].2); + + mock.set_foreground_cmd(child_pid, vec!["cargo".into(), "test".into()]); + pty.pane_activity_flags + .get(&1) + .unwrap() + .store(true, Ordering::Relaxed); + + pty.update_and_report_cwds(); + let events = collect_command_changed_events(&rx); + assert_eq!(events.len(), 1); + assert_eq!(events[0].1, vec!["cargo", "test"]); + assert!(events[0].2); +} + +// --- Activity flag gating --- + +#[test] +fn activity_flag_reset_after_poll() { + let mock = MockOsApi::new(); + let child_pid = 100; + let (mut pty, _rx) = make_pty_with_plugin_receiver(mock); + set_active_terminal(&mut pty, 1, child_pid); + assert!(pty + .pane_activity_flags + .get(&1) + .unwrap() + .load(Ordering::Relaxed)); + + pty.update_and_report_cwds(); + + assert!( + !pty.pane_activity_flags + .get(&1) + .unwrap() + .load(Ordering::Relaxed), + "activity flag should be reset to false after poll" + ); +} + +#[test] +fn multiple_terminals_only_active_ones_polled() { + let mock = MockOsApi::new(); + let pid_active = 100; + let pid_inactive = 200; + mock.set_cwd(pid_active, PathBuf::from("/active")); + mock.set_cwd(pid_inactive, PathBuf::from("/inactive")); + let (mut pty, rx) = make_pty_with_plugin_receiver(mock); + set_active_terminal(&mut pty, 1, pid_active); + set_active_terminal(&mut pty, 2, pid_inactive); + pty.pane_activity_flags + .get(&2) + .unwrap() + .store(false, Ordering::Relaxed); + + pty.update_and_report_cwds(); + + let events = collect_cwd_changed_events(&rx); + assert_eq!(events.len(), 1); + assert_eq!(events[0].0, PaneId::Terminal(1)); + assert_eq!(events[0].1, PathBuf::from("/active")); +} + +// --- CWD change events --- + +#[test] +fn cwd_changed_event_emitted_on_change() { + let mock = MockOsApi::new(); + let child_pid = 100; + mock.set_cwd(child_pid, PathBuf::from("/home/user")); + let (mut pty, rx) = make_pty_with_plugin_receiver(mock); + set_active_terminal(&mut pty, 1, child_pid); + + pty.update_and_report_cwds(); + + let events = collect_cwd_changed_events(&rx); + assert_eq!(events.len(), 1); + assert_eq!(events[0].0, PaneId::Terminal(1)); + assert_eq!(events[0].1, PathBuf::from("/home/user")); +} + +#[test] +fn no_cwd_event_when_unchanged() { + let mock = MockOsApi::new(); + let child_pid = 100; + mock.set_cwd(child_pid, PathBuf::from("/home/user")); + let (mut pty, rx) = make_pty_with_plugin_receiver(mock); + set_active_terminal(&mut pty, 1, child_pid); + pty.terminal_cwds.insert(1, PathBuf::from("/home/user")); + + pty.update_and_report_cwds(); + + let events = collect_cwd_changed_events(&rx); + assert!(events.is_empty(), "no event expected when cwd unchanged"); +} + +// --- OSC7 CWD notification --- + +#[test] +fn osc7_emits_cwd_changed() { + let mock = MockOsApi::new(); + let (mut pty, rx) = make_pty_with_plugin_receiver(mock); + pty.id_to_child_pid.insert(1, 100); + + pty.notify_cwd_from_osc7(1, PathBuf::from("/tmp/new")); + + let events = collect_cwd_changed_events(&rx); + assert_eq!(events.len(), 1); + assert_eq!(events[0].0, PaneId::Terminal(1)); + assert_eq!(events[0].1, PathBuf::from("/tmp/new")); + assert_eq!( + pty.terminal_cwds.get(&1), + Some(&PathBuf::from("/tmp/new")), + "cache should be updated" + ); +} + +#[test] +fn osc7_no_event_when_unchanged() { + let mock = MockOsApi::new(); + let (mut pty, rx) = make_pty_with_plugin_receiver(mock); + pty.id_to_child_pid.insert(1, 100); + pty.terminal_cwds.insert(1, PathBuf::from("/same")); + + pty.notify_cwd_from_osc7(1, PathBuf::from("/same")); + + let events = collect_cwd_changed_events(&rx); + assert!(events.is_empty(), "no event when osc7 path matches cache"); +} + +#[test] +fn osc7_clears_activity_flag() { + let mock = MockOsApi::new(); + let (mut pty, _rx) = make_pty_with_plugin_receiver(mock); + let flag = Arc::new(AtomicBool::new(true)); + pty.id_to_child_pid.insert(1, 100); + pty.pane_activity_flags.insert(1, flag.clone()); + + pty.notify_cwd_from_osc7(1, PathBuf::from("/new")); + + assert!( + !flag.load(Ordering::Relaxed), + "osc7 should clear the activity flag" + ); +} + +#[test] +fn osc7_then_poll_skips_terminal() { + let mock = MockOsApi::new(); + let child_pid = 100; + mock.set_cwd(child_pid, PathBuf::from("/from-proc")); + mock.set_foreground_cmd(child_pid, vec!["vim".into()]); + let (mut pty, rx) = make_pty_with_plugin_receiver(mock); + set_active_terminal(&mut pty, 1, child_pid); + + pty.notify_cwd_from_osc7(1, PathBuf::from("/from-osc7")); + let osc7_events = collect_cwd_changed_events(&rx); + assert_eq!(osc7_events.len(), 1); + + pty.update_and_report_cwds(); + let cwd_events = collect_cwd_changed_events(&rx); + let cmd_events = collect_command_changed_events(&rx); + assert!( + cwd_events.is_empty() && cmd_events.is_empty(), + "poll after osc7 should skip terminal since flag was cleared" + ); +} diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs index 44f0d31a..27304131 100644 --- a/zellij-server/src/unit/screen_tests.rs +++ b/zellij-server/src/unit/screen_tests.rs @@ -152,9 +152,9 @@ fn send_cli_action_to_server( capabilities, client_attributes.clone(), default_shell.clone(), - default_layout.clone(), + &default_layout, None, - client_keybinds.clone(), + &client_keybinds, default_mode, None, ) diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs index 5382dde4..92c37099 100644 --- a/zellij-tile/src/shim.rs +++ b/zellij-tile/src/shim.rs @@ -14,31 +14,32 @@ use zellij_utils::plugin_api::generated_api::api::plugin_command::{ }; use zellij_utils::plugin_api::plugin_command::{ dump_layout_response, dump_session_layout_response, get_focused_pane_info_response, - get_pane_cwd_response, get_pane_running_command_response, parse_layout_response, - CreateTokenResponse, ListTokensResponse, ProtobufBreakPanesToNewTabResponse, - ProtobufBreakPanesToTabWithIdResponse, ProtobufBreakPanesToTabWithIndexResponse, - ProtobufCurrentSessionLastSavedTimeResponse, ProtobufDeleteLayoutResponse, - ProtobufDumpLayoutResponse, ProtobufDumpSessionLayoutResponse, ProtobufEditLayoutResponse, - ProtobufFocusOrCreateTabResponse, ProtobufGenerateRandomNameResponse, - ProtobufGetFocusedPaneInfoResponse, ProtobufGetLayoutDirResponse, ProtobufGetPaneCwdResponse, - ProtobufGetPaneInfoResponse, ProtobufGetPanePidResponse, ProtobufGetPaneRunningCommandResponse, - ProtobufGetSessionEnvironmentVariablesResponse, ProtobufGetTabInfoResponse, - ProtobufHideFloatingPanesResponse, ProtobufNewTabResponse, ProtobufNewTabsResponse, - ProtobufOpenCommandPaneBackgroundResponse, ProtobufOpenCommandPaneFloatingNearPluginResponse, - ProtobufOpenCommandPaneFloatingResponse, ProtobufOpenCommandPaneInPlaceOfPaneIdResponse, - ProtobufOpenCommandPaneInPlaceOfPluginResponse, ProtobufOpenCommandPaneInPlaceResponse, - ProtobufOpenCommandPaneNearPluginResponse, ProtobufOpenCommandPaneResponse, - ProtobufOpenEditPaneInPlaceOfPaneIdResponse, ProtobufOpenFileFloatingNearPluginResponse, - ProtobufOpenFileFloatingResponse, ProtobufOpenFileInPlaceOfPluginResponse, - ProtobufOpenFileInPlaceResponse, ProtobufOpenFileNearPluginResponse, ProtobufOpenFileResponse, - ProtobufOpenPaneInNewTabResponse, ProtobufOpenPluginPaneFloatingResponse, - ProtobufOpenTerminalFloatingNearPluginResponse, ProtobufOpenTerminalFloatingResponse, - ProtobufOpenTerminalInPlaceOfPluginResponse, ProtobufOpenTerminalInPlaceResponse, - ProtobufOpenTerminalNearPluginResponse, ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse, - ProtobufOpenTerminalResponse, ProtobufParseLayoutResponse, ProtobufPluginCommand, - ProtobufRenameLayoutResponse, ProtobufSaveLayoutResponse, ProtobufSaveSessionResponse, - ProtobufShowFloatingPanesResponse, RenameWebTokenResponse, RevokeAllWebTokensResponse, - RevokeTokenResponse, + get_pane_cwd_response, get_pane_running_command_response, get_session_list_response, + parse_layout_response, CreateTokenResponse, ListTokensResponse, + ProtobufBreakPanesToNewTabResponse, ProtobufBreakPanesToTabWithIdResponse, + ProtobufBreakPanesToTabWithIndexResponse, ProtobufCurrentSessionLastSavedTimeResponse, + ProtobufDeleteLayoutResponse, ProtobufDumpLayoutResponse, ProtobufDumpSessionLayoutResponse, + ProtobufEditLayoutResponse, ProtobufFocusOrCreateTabResponse, + ProtobufGenerateRandomNameResponse, ProtobufGetFocusedPaneInfoResponse, + ProtobufGetLayoutDirResponse, ProtobufGetPaneCwdResponse, ProtobufGetPaneInfoResponse, + ProtobufGetPanePidResponse, ProtobufGetPaneRunningCommandResponse, + ProtobufGetSessionEnvironmentVariablesResponse, ProtobufGetSessionListResponse, + ProtobufGetTabInfoResponse, ProtobufHideFloatingPanesResponse, ProtobufNewTabResponse, + ProtobufNewTabsResponse, ProtobufOpenCommandPaneBackgroundResponse, + ProtobufOpenCommandPaneFloatingNearPluginResponse, ProtobufOpenCommandPaneFloatingResponse, + ProtobufOpenCommandPaneInPlaceOfPaneIdResponse, ProtobufOpenCommandPaneInPlaceOfPluginResponse, + ProtobufOpenCommandPaneInPlaceResponse, ProtobufOpenCommandPaneNearPluginResponse, + ProtobufOpenCommandPaneResponse, ProtobufOpenEditPaneInPlaceOfPaneIdResponse, + ProtobufOpenFileFloatingNearPluginResponse, ProtobufOpenFileFloatingResponse, + ProtobufOpenFileInPlaceOfPluginResponse, ProtobufOpenFileInPlaceResponse, + ProtobufOpenFileNearPluginResponse, ProtobufOpenFileResponse, ProtobufOpenPaneInNewTabResponse, + ProtobufOpenPluginPaneFloatingResponse, ProtobufOpenTerminalFloatingNearPluginResponse, + ProtobufOpenTerminalFloatingResponse, ProtobufOpenTerminalInPlaceOfPluginResponse, + ProtobufOpenTerminalInPlaceResponse, ProtobufOpenTerminalNearPluginResponse, + ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse, ProtobufOpenTerminalResponse, + ProtobufParseLayoutResponse, ProtobufPluginCommand, ProtobufRenameLayoutResponse, + ProtobufSaveLayoutResponse, ProtobufSaveSessionResponse, ProtobufShowFloatingPanesResponse, + RenameWebTokenResponse, RevokeAllWebTokensResponse, RevokeTokenResponse, }; use zellij_utils::plugin_api::plugin_ids::{ProtobufPluginIds, ProtobufZellijVersion}; @@ -1902,6 +1903,43 @@ pub fn get_pane_running_command(pane_id: PaneId) -> Result<Vec<String>, String> } } +/// Fetches a fresh snapshot of all live and resurrectable sessions on this machine. +/// +/// # Permissions Required +/// * `ReadApplicationState` +pub fn get_session_list() -> Result<SessionListSnapshot, String> { + let plugin_command = PluginCommand::GetSessionList; + let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); + object_to_stdout(&protobuf_plugin_command.encode_to_vec()); + unsafe { host_run_plugin_command() }; + + let protobuf_response = + ProtobufGetSessionListResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap(); + + match protobuf_response.result { + Some(get_session_list_response::Result::Snapshot(snapshot)) => { + let mut live_sessions = Vec::new(); + for manifest in snapshot.live_sessions { + match SessionInfo::try_from(manifest) { + Ok(si) => live_sessions.push(si), + Err(e) => return Err(format!("Malformed session manifest: {}", e)), + } + } + let resurrectable_sessions = snapshot + .resurrectable_sessions + .into_iter() + .map(<(String, std::time::Duration)>::from) + .collect(); + Ok(SessionListSnapshot { + live_sessions, + resurrectable_sessions, + }) + }, + Some(get_session_list_response::Result::Error(err)) => Err(err), + None => Err("Empty response from server".to_string()), + } +} + /// Gets the current working directory for a specific pane by its ID. /// /// This queries the operating system for the **current** working directory diff --git a/zellij-utils/assets/prost/api.event.rs b/zellij-utils/assets/prost/api.event.rs index 30ace492..2ec490b0 100644 --- a/zellij-utils/assets/prost/api.event.rs +++ b/zellij-utils/assets/prost/api.event.rs @@ -9,7 +9,7 @@ pub struct EventNameList { pub struct Event { #[prost(enumeration="EventType", tag="1")] pub name: i32, - #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38")] + #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39")] pub payload: ::core::option::Option<event::Payload>, } /// Nested message and enum types in `Event`. @@ -91,6 +91,8 @@ pub mod event { PaneRenderReportWithAnsiPayload(super::PaneRenderReportPayload), #[prost(message, tag="38")] InitialKeybindsPayload(super::InitialKeybindsPayload), + #[prost(message, tag="39")] + CommandChangedPayload(super::CommandChangedPayload), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -105,6 +107,18 @@ pub struct CwdChangedPayload { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct CommandChangedPayload { + #[prost(message, optional, tag="1")] + pub pane_id: ::core::option::Option<PaneId>, + #[prost(string, repeated, tag="2")] + pub command: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(bool, tag="3")] + pub is_foreground: bool, + #[prost(uint32, repeated, tag="4")] + pub focused_client_ids: ::prost::alloc::vec::Vec<u32>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct FailedToStartWebServerPayload { #[prost(string, tag="1")] pub error: ::prost::alloc::string::String, @@ -815,6 +829,7 @@ pub enum EventType { HighlightClicked = 42, PaneRenderReportWithAnsi = 43, InitialKeybinds = 44, + CommandChanged = 45, } impl EventType { /// String value of the enum field names used in the ProtoBuf definition. @@ -867,6 +882,7 @@ impl EventType { EventType::HighlightClicked => "HighlightClicked", EventType::PaneRenderReportWithAnsi => "PaneRenderReportWithAnsi", EventType::InitialKeybinds => "InitialKeybinds", + EventType::CommandChanged => "CommandChanged", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -916,6 +932,7 @@ impl EventType { "HighlightClicked" => Some(Self::HighlightClicked), "PaneRenderReportWithAnsi" => Some(Self::PaneRenderReportWithAnsi), "InitialKeybinds" => Some(Self::InitialKeybinds), + "CommandChanged" => Some(Self::CommandChanged), _ => None, } } diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs index 71a843dd..e4c5a03a 100644 --- a/zellij-utils/assets/prost/api.plugin_command.rs +++ b/zellij-utils/assets/prost/api.plugin_command.rs @@ -3,7 +3,7 @@ pub struct PluginCommand { #[prost(enumeration="CommandName", tag="1")] pub name: i32, - #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160")] + #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161")] pub payload: ::core::option::Option<plugin_command::Payload>, } /// Nested message and enum types in `PluginCommand`. @@ -303,6 +303,8 @@ pub mod plugin_command { ClearPaneHighlightsPayload(super::ClearPaneHighlightsPayload), #[prost(message, tag="160")] OpenPluginPaneFloatingPayload(super::OpenPluginPaneFloatingPayload), + #[prost(message, tag="161")] + GetSessionListPayload(super::GetSessionListPayload), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -1899,6 +1901,35 @@ pub struct OpenPluginPaneFloatingResponse { #[prost(message, optional, tag="1")] pub pane_id: ::core::option::Option<PaneId>, } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetSessionListPayload { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SessionListSnapshot { + #[prost(message, repeated, tag="1")] + pub live_sessions: ::prost::alloc::vec::Vec<super::event::SessionManifest>, + #[prost(message, repeated, tag="2")] + pub resurrectable_sessions: ::prost::alloc::vec::Vec<super::event::ResurrectableSession>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetSessionListResponse { + #[prost(oneof="get_session_list_response::Result", tags="1, 2")] + pub result: ::core::option::Option<get_session_list_response::Result>, +} +/// Nested message and enum types in `GetSessionListResponse`. +pub mod get_session_list_response { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(message, tag="1")] + Snapshot(super::SessionListSnapshot), + #[prost(string, tag="2")] + Error(::prost::alloc::string::String), + } +} #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum CommandName { @@ -2092,6 +2123,7 @@ pub enum CommandName { ClearPaneHighlights = 208, OpenPluginPaneFloating = 209, ListWindowsVolumes = 210, + GetSessionList = 211, } impl CommandName { /// String value of the enum field names used in the ProtoBuf definition. @@ -2290,6 +2322,7 @@ impl CommandName { CommandName::ClearPaneHighlights => "ClearPaneHighlights", CommandName::OpenPluginPaneFloating => "OpenPluginPaneFloating", CommandName::ListWindowsVolumes => "ListWindowsVolumes", + CommandName::GetSessionList => "GetSessionList", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -2485,6 +2518,7 @@ impl CommandName { "ClearPaneHighlights" => Some(Self::ClearPaneHighlights), "OpenPluginPaneFloating" => Some(Self::OpenPluginPaneFloating), "ListWindowsVolumes" => Some(Self::ListWindowsVolumes), + "GetSessionList" => Some(Self::GetSessionList), _ => None, } } diff --git a/zellij-utils/src/consts.rs b/zellij-utils/src/consts.rs index 75b9d4eb..32bb8153 100644 --- a/zellij-utils/src/consts.rs +++ b/zellij-utils/src/consts.rs @@ -51,6 +51,29 @@ pub fn create_config_and_cache_folders() { if let Err(e) = std::fs::create_dir_all(&ZELLIJ_SESSION_INFO_CACHE_DIR.as_path()) { log::error!("Failed to create session_info cache dir: {:?}", e); } + prune_empty_session_info_folders(); +} + +fn prune_empty_session_info_folders() { + let Ok(entries) = std::fs::read_dir(&*ZELLIJ_SESSION_INFO_CACHE_DIR) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let is_empty = std::fs::read_dir(&path) + .ok() + .map_or(false, |mut iter| iter.next().is_none()); + if is_empty { + if let Err(e) = std::fs::remove_dir(&path) { + if e.kind() != std::io::ErrorKind::NotFound { + log::debug!("Failed to prune empty session folder {:?}: {:?}", path, e); + } + } + } + } } const fn system_default_data_dir() -> &'static str { diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index 9e756fb1..cfcc3692 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -1012,6 +1012,7 @@ pub enum Event { PaneRenderReportWithAnsi(HashMap<PaneId, PaneContents>), ActionComplete(Action, Option<PaneId>, BTreeMap<String, String>), // Action, pane_id, context CwdChanged(PaneId, PathBuf, Vec<ClientId>), // pane_id, cwd, focused_client_ids + CommandChanged(PaneId, Vec<String>, bool, Vec<ClientId>), // pane_id, command, is_foreground, focused_client_ids AvailableLayoutInfo(Vec<LayoutInfo>, Vec<LayoutWithError>), PluginConfigurationChanged(BTreeMap<String, String>), HighlightClicked { @@ -2580,6 +2581,18 @@ pub enum GetPaneRunningCommandResponse { Err(String), } +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionListSnapshot { + pub live_sessions: Vec<SessionInfo>, + pub resurrectable_sessions: Vec<(String, Duration)>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum GetSessionListResponse { + Ok(SessionListSnapshot), + Err(String), +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum GetPaneCwdResponse { Ok(PathBuf), @@ -3566,6 +3579,7 @@ pub enum PluginCommand { context: BTreeMap<String, String>, }, ListWindowsVolumes, + GetSessionList, } // Response type for plugin API methods that open a pane in a new tab diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs index d44a348a..5bb8e955 100644 --- a/zellij-utils/src/errors.rs +++ b/zellij-utils/src/errors.rs @@ -487,6 +487,7 @@ pub enum PtyContext { GetPaneRunningCommand, GetPaneCwd, UpdateAndReportCwds, + NotifyCwdFromOsc7, Exit, } @@ -614,7 +615,6 @@ pub enum BackgroundJobContext { DisplayPaneError, AnimatePluginLoading, StopPluginLoadingAnimation, - ReadAllSessionInfosOnMachine, ReportSessionInfo, ReportLayoutInfo, RunCommand, diff --git a/zellij-utils/src/plugin_api/event.proto b/zellij-utils/src/plugin_api/event.proto index ce9eb8ff..3c3a8490 100644 --- a/zellij-utils/src/plugin_api/event.proto +++ b/zellij-utils/src/plugin_api/event.proto @@ -67,6 +67,7 @@ enum EventType { HighlightClicked = 42; PaneRenderReportWithAnsi = 43; InitialKeybinds = 44; + CommandChanged = 45; } message EventNameList { @@ -113,6 +114,7 @@ message Event { HighlightClickedPayload highlight_clicked_payload = 36; PaneRenderReportPayload pane_render_report_with_ansi_payload = 37; InitialKeybindsPayload initial_keybinds_payload = 38; + CommandChangedPayload command_changed_payload = 39; } } @@ -122,6 +124,13 @@ message CwdChangedPayload { repeated uint32 focused_client_ids = 3; } +message CommandChangedPayload { + PaneId pane_id = 1; + repeated string command = 2; + bool is_foreground = 3; + repeated uint32 focused_client_ids = 4; +} + message FailedToStartWebServerPayload { string error = 1; } diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs index 93b28365..6f138742 100644 --- a/zellij-utils/src/plugin_api/event.rs +++ b/zellij-utils/src/plugin_api/event.rs @@ -6,7 +6,8 @@ pub use super::generated_api::api::{ pane_scrollback_response, ActionCompletePayload as ProtobufActionCompletePayload, AvailableLayoutInfoPayload as ProtobufAvailableLayoutInfoPayload, ClientInfo as ProtobufClientInfo, ClientPaneHistory as ProtobufClientPaneHistory, - ClientTabHistory as ProtobufClientTabHistory, ContextItem as ProtobufContextItem, + ClientTabHistory as ProtobufClientTabHistory, + CommandChangedPayload as ProtobufCommandChangedPayload, ContextItem as ProtobufContextItem, CopyDestination as ProtobufCopyDestination, CwdChangedPayload as ProtobufCwdChangedPayload, Event as ProtobufEvent, EventNameList as ProtobufEventNameList, EventType as ProtobufEventType, FileMetadata as ProtobufFileMetadata, @@ -474,6 +475,27 @@ impl TryFrom<ProtobufEvent> for Event { }, _ => Err("Malformed payload for the CwdChanged Event"), }, + Some(ProtobufEventType::CommandChanged) => match protobuf_event.payload { + Some(ProtobufEventPayload::CommandChangedPayload(p)) => { + let pane_id: PaneId = p + .pane_id + .ok_or("Missing pane_id in CommandChanged payload")? + .try_into() + .map_err(|_| "Failed to convert PaneId in CommandChanged payload")?; + let focused_client_ids: Vec<ClientId> = p + .focused_client_ids + .into_iter() + .map(|id| id as u16) + .collect(); + Ok(Event::CommandChanged( + pane_id, + p.command, + p.is_foreground, + focused_client_ids, + )) + }, + _ => Err("Malformed payload for the CommandChanged Event"), + }, Some(ProtobufEventType::AvailableLayoutInfo) => match protobuf_event.payload { Some(ProtobufEventPayload::AvailableLayoutInfoPayload( available_layout_info_payload, @@ -1014,6 +1036,21 @@ impl TryFrom<Event> for ProtobufEvent { payload: Some(event::Payload::CwdChangedPayload(cwd_changed_payload)), }) }, + Event::CommandChanged(pane_id, command, is_foreground, focused_client_ids) => { + let protobuf_pane_id: ProtobufPaneId = pane_id.try_into()?; + let focused_client_ids_u32: Vec<u32> = + focused_client_ids.into_iter().map(|id| id as u32).collect(); + let payload = ProtobufCommandChangedPayload { + pane_id: Some(protobuf_pane_id), + command, + is_foreground, + focused_client_ids: focused_client_ids_u32, + }; + Ok(ProtobufEvent { + name: ProtobufEventType::CommandChanged as i32, + payload: Some(event::Payload::CommandChangedPayload(payload)), + }) + }, Event::AvailableLayoutInfo(available_layouts, layouts_with_errors) => { let mut protobuf_available_layouts = vec![]; let mut protobuf_layouts_with_errors = vec![]; @@ -2015,6 +2052,7 @@ impl TryFrom<ProtobufEventType> for EventType { ProtobufEventType::UserAction => EventType::UserAction, ProtobufEventType::ActionComplete => EventType::ActionComplete, ProtobufEventType::CwdChanged => EventType::CwdChanged, + ProtobufEventType::CommandChanged => EventType::CommandChanged, ProtobufEventType::AvailableLayoutInfo => EventType::AvailableLayoutInfo, ProtobufEventType::PluginConfigurationChanged => EventType::PluginConfigurationChanged, ProtobufEventType::HighlightClicked => EventType::HighlightClicked, @@ -2067,6 +2105,7 @@ impl TryFrom<EventType> for ProtobufEventType { EventType::UserAction => ProtobufEventType::UserAction, EventType::ActionComplete => ProtobufEventType::ActionComplete, EventType::CwdChanged => ProtobufEventType::CwdChanged, + EventType::CommandChanged => ProtobufEventType::CommandChanged, EventType::AvailableLayoutInfo => ProtobufEventType::AvailableLayoutInfo, EventType::PluginConfigurationChanged => ProtobufEventType::PluginConfigurationChanged, EventType::HighlightClicked => ProtobufEventType::HighlightClicked, diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto index 63baa443..0b768838 100644 --- a/zellij-utils/src/plugin_api/plugin_command.proto +++ b/zellij-utils/src/plugin_api/plugin_command.proto @@ -203,6 +203,7 @@ enum CommandName { ClearPaneHighlights = 208; OpenPluginPaneFloating = 209; ListWindowsVolumes = 210; + GetSessionList = 211; } message PluginCommand { @@ -354,6 +355,7 @@ message PluginCommand { SetPaneRegexHighlightsPayload set_pane_regex_highlights_payload = 158; ClearPaneHighlightsPayload clear_pane_highlights_payload = 159; OpenPluginPaneFloatingPayload open_plugin_pane_floating_payload = 160; + GetSessionListPayload get_session_list_payload = 161; } } @@ -1295,3 +1297,17 @@ message OpenPluginPaneFloatingPayload { message OpenPluginPaneFloatingResponse { optional PaneId pane_id = 1; } + +message GetSessionListPayload {} + +message SessionListSnapshot { + repeated event.SessionManifest live_sessions = 1; + repeated event.ResurrectableSession resurrectable_sessions = 2; +} + +message GetSessionListResponse { + oneof result { + SessionListSnapshot snapshot = 1; + string error = 2; + } +} diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs index a457f720..5bfb2b42 100644 --- a/zellij-utils/src/plugin_api/plugin_command.rs +++ b/zellij-utils/src/plugin_api/plugin_command.rs @@ -1,13 +1,17 @@ pub use super::generated_api::api::{ action::{Action as ProtobufAction, PaneIdAndShouldFloat, SwitchToModePayload}, - event::{EventNameList as ProtobufEventNameList, Header}, + event::{ + EventNameList as ProtobufEventNameList, Header, + ResurrectableSession as ProtobufResurrectableSession, + SessionManifest as ProtobufSessionManifest, + }, input_mode::InputMode as ProtobufInputMode, plugin_command::{ break_panes_to_new_tab_response, break_panes_to_tab_with_id_response, break_panes_to_tab_with_index_response, delete_layout_response, dump_layout_response, dump_session_layout_response, edit_layout_response, focus_or_create_tab_response, get_focused_pane_info_response, get_pane_cwd_response, get_pane_pid_response, - get_pane_running_command_response, hide_floating_panes_response, + get_pane_running_command_response, get_session_list_response, hide_floating_panes_response, highlight_style::Style as ProtobufHighlightStyleVariant, new_tab_response, parse_layout_response, plugin_command::Payload, rename_layout_response, save_layout_response, save_session_response, show_floating_panes_response, @@ -48,7 +52,9 @@ pub use super::generated_api::api::{ GetPaneScrollbackPayload, GetSessionEnvironmentVariablesPayload as ProtobufGetSessionEnvironmentVariablesPayload, GetSessionEnvironmentVariablesResponse as ProtobufGetSessionEnvironmentVariablesResponse, - GetTabInfoPayload, GetTabInfoResponse as ProtobufGetTabInfoResponse, GoToTabWithIdPayload, + GetSessionListPayload as ProtobufGetSessionListPayload, + GetSessionListResponse as ProtobufGetSessionListResponse, GetTabInfoPayload, + GetTabInfoResponse as ProtobufGetTabInfoResponse, GoToTabWithIdPayload, GroupAndUngroupPanesPayload, HideFloatingPanesPayload as ProtobufHideFloatingPanesPayload, HideFloatingPanesResponse as ProtobufHideFloatingPanesResponse, HidePaneWithIdPayload, HighlightAndUnhighlightPanesPayload, HighlightLayer as ProtobufHighlightLayer, @@ -108,9 +114,10 @@ pub use super::generated_api::api::{ SaveLayoutPayload, SaveLayoutResponse as ProtobufSaveLayoutResponse, SaveSessionPayload, SaveSessionResponse as ProtobufSaveSessionResponse, ScrollDownInPaneIdPayload, ScrollToBottomInPaneIdPayload, ScrollToTopInPaneIdPayload, ScrollUpInPaneIdPayload, - SetFloatingPanePinnedPayload, SetPaneBorderlessPayload, SetPaneColorPayload, - SetPaneRegexHighlightsPayload, SetSelfMouseSelectionSupportPayload, SetTimeoutPayload, - ShowCursorPayload, ShowFloatingPanesPayload as ProtobufShowFloatingPanesPayload, + SessionListSnapshot as ProtobufSessionListSnapshot, SetFloatingPanePinnedPayload, + SetPaneBorderlessPayload, SetPaneColorPayload, SetPaneRegexHighlightsPayload, + SetSelfMouseSelectionSupportPayload, SetTimeoutPayload, ShowCursorPayload, + ShowFloatingPanesPayload as ProtobufShowFloatingPanesPayload, ShowFloatingPanesResponse as ProtobufShowFloatingPanesResponse, ShowPaneWithIdPayload, StackPanesPayload, SubscribePayload, SwitchSessionPayload, SwitchTabToIdPayload, SwitchTabToPayload, TogglePaneBorderlessPayload, TogglePaneEmbedOrEjectForPaneIdPayload, @@ -124,9 +131,10 @@ pub use super::generated_api::api::{ use crate::data::{ ConnectToSession, DeleteLayoutResponse, EditLayoutResponse, FloatingPaneCoordinates, GetFocusedPaneInfoResponse, GetPaneCwdResponse, GetPanePidResponse, - GetPaneRunningCommandResponse, HighlightLayer, HighlightStyle, HttpVerb, InputMode, - KeyWithModifier, MessageToPlugin, NewPluginArgs, PaneId, PermissionType, PluginCommand, - RegexHighlight, RenameLayoutResponse, SaveLayoutResponse, + GetPaneRunningCommandResponse, GetSessionListResponse, HighlightLayer, HighlightStyle, + HttpVerb, InputMode, KeyWithModifier, MessageToPlugin, NewPluginArgs, PaneId, PermissionType, + PluginCommand, RegexHighlight, RenameLayoutResponse, SaveLayoutResponse, SessionInfo, + SessionListSnapshot, }; use crate::input::actions::Action; use crate::input::layout::PercentOrFixed; @@ -329,6 +337,63 @@ impl From<GetPaneRunningCommandResponse> for ProtobufGetPaneRunningCommandRespon } } +impl From<GetSessionListResponse> for ProtobufGetSessionListResponse { + fn from(response: GetSessionListResponse) -> Self { + match response { + GetSessionListResponse::Ok(snapshot) => { + let live_sessions = snapshot + .live_sessions + .into_iter() + .filter_map(|si| ProtobufSessionManifest::try_from(si).ok()) + .collect(); + let resurrectable_sessions = snapshot + .resurrectable_sessions + .into_iter() + .map(|entry| ProtobufResurrectableSession::from(entry)) + .collect(); + ProtobufGetSessionListResponse { + result: Some(get_session_list_response::Result::Snapshot( + ProtobufSessionListSnapshot { + live_sessions, + resurrectable_sessions, + }, + )), + } + }, + GetSessionListResponse::Err(err) => ProtobufGetSessionListResponse { + result: Some(get_session_list_response::Result::Error(err)), + }, + } + } +} + +impl TryFrom<ProtobufGetSessionListResponse> for GetSessionListResponse { + type Error = &'static str; + fn try_from(protobuf_response: ProtobufGetSessionListResponse) -> Result<Self, &'static str> { + match protobuf_response.result { + Some(get_session_list_response::Result::Snapshot(snapshot)) => { + let mut live_sessions: Vec<SessionInfo> = Vec::new(); + for manifest in snapshot.live_sessions { + live_sessions.push(SessionInfo::try_from(manifest)?); + } + let resurrectable_sessions = snapshot + .resurrectable_sessions + .into_iter() + .map(<(String, std::time::Duration)>::from) + .collect(); + Ok(GetSessionListResponse::Ok(SessionListSnapshot { + live_sessions, + resurrectable_sessions, + })) + }, + Some(get_session_list_response::Result::Error(err)) => { + Ok(GetSessionListResponse::Err(err)) + }, + None => Err("Empty GetSessionListResponse"), + } + } +} + impl From<GetPaneCwdResponse> for ProtobufGetPaneCwdResponse { fn from(response: GetPaneCwdResponse) -> Self { match response { @@ -1300,6 +1365,11 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand { Some(_) => Err("ListWindowsVolumes should have no payload"), None => Ok(PluginCommand::ListWindowsVolumes), }, + Some(CommandName::GetSessionList) => match protobuf_plugin_command.payload { + Some(Payload::GetSessionListPayload(_)) => Ok(PluginCommand::GetSessionList), + None => Ok(PluginCommand::GetSessionList), + _ => Err("Mismatched payload for GetSessionList"), + }, Some(CommandName::DumpSessionLayout) => match protobuf_plugin_command.payload { Some(Payload::DumpSessionLayoutPayload(payload)) => { Ok(PluginCommand::DumpSessionLayout { @@ -3145,6 +3215,12 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand { name: CommandName::ListWindowsVolumes as i32, payload: None, }), + PluginCommand::GetSessionList => Ok(ProtobufPluginCommand { + name: CommandName::GetSessionList as i32, + payload: Some(Payload::GetSessionListPayload( + ProtobufGetSessionListPayload {}, + )), + }), PluginCommand::DumpSessionLayout { tab_index } => Ok(ProtobufPluginCommand { name: CommandName::DumpSessionLayout as i32, payload: tab_index.map(|idx| { |
