diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-03-07 16:33:05 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-07 16:33:05 +0100 |
| commit | 58cb2267fd7994ad9abb2df9792ec452a2deab45 (patch) | |
| tree | d0ab9dbbc147bd5db5b7dee5c1687069cc946d99 | |
| parent | Update CHANGELOG.md (diff) | |
| download | sl0ppij-58cb2267fd7994ad9abb2df9792ec452a2deab45.tar.gz sl0ppij-58cb2267fd7994ad9abb2df9792ec452a2deab45.tar.xz sl0ppij-58cb2267fd7994ad9abb2df9792ec452a2deab45.zip | |
Allow subscribing to pane viewport updates (renders) from the CLI (#4814)
* initial subscribe implementation
* tests for subscribe
* update cli help
* rustfmt
* add pr
25 files changed, 1808 insertions, 26 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a794806..86f472c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) * build: fix redundant proto codegen, plugin batch building and testing, and profile mismatch between build and run (https://github.com/zellij-org/zellij/pull/4799, https://github.com/zellij-org/zellij/pull/4801) * feat: click to open file-paths in viewport, allow plugins to highlight viewport text (https://github.com/zellij-org/zellij/pull/4802 and https://github.com/zellij-org/zellij/pull/4809) * fix: better handle STDIN segmentation with latency (eg. over SSH) (https://github.com/zellij-org/zellij/pull/4623) +* feat: `zellij subscribe` -> allow to subscribe to pane viewport/scrollback updates from the cli (https://github.com/zellij-org/zellij/pull/4814) ## [0.43.1] - 2025-08-08 diff --git a/src/commands.rs b/src/commands.rs index d00d1d04..77bdecec 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -442,6 +442,63 @@ pub(crate) fn send_action_to_session( }, }; } +pub(crate) fn subscribe_to_session( + subscribe_cli: zellij_utils::cli::SubscribeCli, + requested_session_name: Option<String>, + _config: Option<Config>, +) { + let session_name = match get_active_session() { + ActiveSession::None => { + eprintln!("There is no active session!"); + std::process::exit(1); + }, + ActiveSession::One(session_name) => { + if let Some(ref requested) = requested_session_name { + if *requested != session_name { + eprintln!( + "Session '{}' not found. The following sessions are active:", + requested + ); + eprintln!("{}", session_name); + std::process::exit(1); + } + } + session_name + }, + ActiveSession::Many => { + let existing_sessions: Vec<String> = get_sessions() + .unwrap_or_default() + .iter() + .map(|s| s.0.clone()) + .collect(); + if let Some(session_name) = requested_session_name { + if existing_sessions.contains(&session_name) { + session_name + } else { + eprintln!( + "Session '{}' not found. The following sessions are active:", + session_name + ); + list_sessions(false, false, true); + std::process::exit(1); + } + } else if let Ok(session_name) = envs::get_session_name() { + session_name + } else { + eprintln!("Please specify the session name to subscribe to. The following sessions are active:"); + list_sessions(false, false, true); + std::process::exit(1); + } + }, + }; + let os_input = get_os_input(zellij_client::os_input_output::get_cli_client_os_input); + zellij_client::cli_client::start_subscribe_client( + Box::new(os_input), + &session_name, + subscribe_cli, + ); +} + pub(crate) fn convert_old_config_file(old_config_file: PathBuf) { match File::open(&old_config_file) { Ok(mut handle) => { diff --git a/src/main.rs b/src/main.rs index 1a0db7e1..ddca0f27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,10 @@ fn main() { commands::send_action_to_session(*cli_action, opts.session, config); std::process::exit(0); } + if let Some(Command::Subscribe(subscribe_cli)) = opts.command { + commands::subscribe_to_session(subscribe_cli, opts.session, config); + std::process::exit(0); + } if let Some(Command::Sessions(Sessions::Run { command, direction, diff --git a/zellij-client/src/cli_client.rs b/zellij-client/src/cli_client.rs index a82e677e..909fa8a2 100644 --- a/zellij-client/src/cli_client.rs +++ b/zellij-client/src/cli_client.rs @@ -1,13 +1,16 @@ //! The `[cli_client]` is used to attach to a running server session //! and dispatch actions, that are specified through the command line. -use std::collections::BTreeMap; -use std::io::BufRead; +use std::collections::{BTreeMap, HashSet}; +use std::io::{self, BufRead, Write}; use std::process; +use std::str::FromStr; use std::{fs, path::PathBuf}; use crate::os_input_output::ClientOsApi; use uuid::Uuid; use zellij_utils::{ + cli::{SubscribeCli, SubscribeFormat}, + data::PaneId, errors::prelude::*, input::actions::Action, ipc::{ClientToServerMsg, ExitReason, ServerToClientMsg}, @@ -241,3 +244,108 @@ fn individual_messages_client( } } } + +pub fn start_subscribe_client( + mut os_input: Box<dyn ClientOsApi>, + session_name: &str, + subscribe_cli: SubscribeCli, +) { + let zellij_ipc_pipe: PathBuf = { + let mut sock_dir = zellij_utils::consts::ZELLIJ_SOCK_DIR.clone(); + fs::create_dir_all(&sock_dir).unwrap(); + zellij_utils::shared::set_permissions(&sock_dir, 0o700).unwrap(); + sock_dir.push(session_name); + sock_dir + }; + os_input.connect_to_server(&*zellij_ipc_pipe); + + // Parse pane IDs + let pane_ids: Vec<PaneId> = subscribe_cli + .pane_id + .iter() + .map(|s| { + PaneId::from_str(s).unwrap_or_else(|e| { + eprintln!("Invalid pane ID '{}': {}", s, e); + process::exit(2); + }) + }) + .collect(); + + // Send subscribe message + os_input.send_to_server(ClientToServerMsg::SubscribeToPaneRenders { + pane_ids: pane_ids.clone(), + scrollback: subscribe_cli.scrollback, + }); + + // Track remaining panes for exit-on-all-closed + let mut remaining_panes: HashSet<PaneId> = pane_ids.into_iter().collect(); + + // Streaming receive loop + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + + loop { + match os_input.recv_from_server() { + Some(( + ServerToClientMsg::PaneRenderUpdate { + pane_id, + viewport, + scrollback, + is_initial, + }, + _, + )) => match subscribe_cli.format { + SubscribeFormat::Raw => { + if let Some(ref scrollback_lines) = scrollback { + for line in scrollback_lines { + let _ = writeln!(stdout, "{}", line); + } + } + for line in &viewport { + let _ = writeln!(stdout, "{}", line); + } + let _ = stdout.flush(); + }, + SubscribeFormat::Json => { + let json = serde_json::json!({ + "event": "pane_update", + "pane_id": pane_id.to_string(), + "viewport": viewport, + "scrollback": scrollback, + "is_initial": is_initial, + }); + let _ = writeln!(stdout, "{}", json); + let _ = stdout.flush(); + }, + }, + Some((ServerToClientMsg::SubscribedPaneClosed { pane_id }, _)) => { + remaining_panes.remove(&pane_id); + match subscribe_cli.format { + SubscribeFormat::Raw => {}, + SubscribeFormat::Json => { + let json = serde_json::json!({ + "event": "pane_closed", + "pane_id": pane_id.to_string(), + }); + let _ = writeln!(stdout, "{}", json); + let _ = stdout.flush(); + }, + } + if remaining_panes.is_empty() { + break; + } + }, + Some((ServerToClientMsg::Exit { .. }, _)) => break, + Some((ServerToClientMsg::LogError { lines }, _)) => { + for line in lines { + eprintln!("{}", line); + } + process::exit(2); + }, + None => break, + _ => {}, + } + } + + os_input.send_to_server(ClientToServerMsg::ClientExited); +} diff --git a/zellij-client/src/lib.rs b/zellij-client/src/lib.rs index dcb4bebb..002fa048 100644 --- a/zellij-client/src/lib.rs +++ b/zellij-client/src/lib.rs @@ -184,6 +184,9 @@ impl From<ServerToClientMsg> for ClientInstruction { ServerToClientMsg::StartWebServer => ClientInstruction::StartWebServer, ServerToClientMsg::RenamedSession { name } => ClientInstruction::RenamedSession(name), ServerToClientMsg::ConfigFileUpdated => ClientInstruction::ConfigFileUpdated, + // Subscribe-only messages — not handled by regular interactive clients + ServerToClientMsg::PaneRenderUpdate { .. } => ClientInstruction::UnblockInputThread, + ServerToClientMsg::SubscribedPaneClosed { .. } => ClientInstruction::UnblockInputThread, } } } diff --git a/zellij-client/src/web_client/server_listener.rs b/zellij-client/src/web_client/server_listener.rs index 4d2b6b7b..bcd83425 100644 --- a/zellij-client/src/web_client/server_listener.rs +++ b/zellij-client/src/web_client/server_listener.rs @@ -225,6 +225,9 @@ pub fn zellij_server_listener( } } }, + // Subscribe-only messages — not relevant for web clients + Some(ServerToClientMsg::PaneRenderUpdate { .. }) => {}, + Some(ServerToClientMsg::SubscribedPaneClosed { .. }) => {}, None => { if unknown_message_count >= 1000 { log::error!("Error: Received more than 1000 consecutive unknown server messages, disconnecting."); diff --git a/zellij-server/src/panes/floating_panes/mod.rs b/zellij-server/src/panes/floating_panes/mod.rs index cfe27812..ed0bb54c 100644 --- a/zellij-server/src/panes/floating_panes/mod.rs +++ b/zellij-server/src/panes/floating_panes/mod.rs @@ -406,7 +406,7 @@ impl FloatingPanes { output.add_pane_contents( &connected_clients, pane.pid(), - pane.pane_contents(None, false), + pane.pane_contents(None, false, None), ); }, PaneId::Plugin(_) => { @@ -414,7 +414,7 @@ impl FloatingPanes { output.add_pane_contents( &[*client_id], pane.pid(), - pane.pane_contents(Some(*client_id), false), + pane.pane_contents(Some(*client_id), false, None), ); } }, diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs index b6b64abf..f551abe7 100644 --- a/zellij-server/src/panes/grid.rs +++ b/zellij-server/src/panes/grid.rs @@ -3025,7 +3025,11 @@ impl Grid { pub fn has_selection(&self) -> bool { !self.selection.is_empty() } - pub fn pane_contents(&self, get_full_scrollback: bool) -> PaneContents { + pub fn pane_contents( + &self, + get_full_scrollback: bool, + max_scrollback_lines: Option<usize>, + ) -> PaneContents { let mut viewport: Vec<String> = Vec::with_capacity(self.viewport.len()); for row in &self.viewport { let s: String = (&row.columns).into_iter().map(|x| x.character).collect(); @@ -3037,6 +3041,13 @@ impl Grid { let s: String = (&row.columns).into_iter().map(|x| x.character).collect(); lines_above_viewport.push(s); } + // Truncate to last N lines if max specified (Some(0) means "all" — no truncation) + if let Some(max) = max_scrollback_lines { + if max > 0 && lines_above_viewport.len() > max { + let start = lines_above_viewport.len() - max; + lines_above_viewport = lines_above_viewport.split_off(start); + } + } let mut lines_below_viewport: Vec<String> = Vec::with_capacity(self.lines_below.len()); for row in &self.lines_below { let s: String = (&row.columns).into_iter().map(|x| x.character).collect(); diff --git a/zellij-server/src/panes/plugin_pane.rs b/zellij-server/src/panes/plugin_pane.rs index b318f539..c2960949 100644 --- a/zellij-server/src/panes/plugin_pane.rs +++ b/zellij-server/src/panes/plugin_pane.rs @@ -885,10 +885,11 @@ impl Pane for PluginPane { &self, client_id: Option<ClientId>, get_full_scrollback: bool, + max_scrollback_lines: Option<usize>, ) -> PaneContents { client_id .and_then(|c| self.grids.get(&c)) - .map(|g| g.pane_contents(get_full_scrollback)) + .map(|g| g.pane_contents(get_full_scrollback, max_scrollback_lines)) .unwrap_or_else(Default::default) } } diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs index 52be384d..d4b43297 100644 --- a/zellij-server/src/panes/terminal_pane.rs +++ b/zellij-server/src/panes/terminal_pane.rs @@ -943,8 +943,10 @@ impl Pane for TerminalPane { &self, _client_id: Option<ClientId>, get_full_scrollback: bool, + max_scrollback_lines: Option<usize>, ) -> PaneContents { - self.grid.pane_contents(get_full_scrollback) + self.grid + .pane_contents(get_full_scrollback, max_scrollback_lines) } fn update_exit_status(&mut self, exit_status: i32) { if let Some(notification_end) = self.notification_end.as_mut() { diff --git a/zellij-server/src/panes/tiled_panes/mod.rs b/zellij-server/src/panes/tiled_panes/mod.rs index 4c8020ac..42bb4f0b 100644 --- a/zellij-server/src/panes/tiled_panes/mod.rs +++ b/zellij-server/src/panes/tiled_panes/mod.rs @@ -1046,7 +1046,7 @@ impl TiledPanes { output.add_pane_contents( &connected_clients, pane.pid(), - pane.pane_contents(None, false), + pane.pane_contents(None, false, None), ); }, PaneId::Plugin(_) => { @@ -1054,7 +1054,7 @@ impl TiledPanes { output.add_pane_contents( &[*client_id], pane.pid(), - pane.pane_contents(Some(*client_id), false), + pane.pane_contents(Some(*client_id), false, None), ); } }, diff --git a/zellij-server/src/panes/tiled_panes/unit/stacked_panes_tests.rs b/zellij-server/src/panes/tiled_panes/unit/stacked_panes_tests.rs index e4adfbe4..651b61dd 100644 --- a/zellij-server/src/panes/tiled_panes/unit/stacked_panes_tests.rs +++ b/zellij-server/src/panes/tiled_panes/unit/stacked_panes_tests.rs @@ -1240,6 +1240,7 @@ impl Pane for MockPane { &self, _client_id: Option<ClientId>, _get_full_scrollback: bool, + _max_scrollback_lines: Option<usize>, ) -> PaneContents { unimplemented!() } diff --git a/zellij-server/src/panes/unit/grid_tests.rs b/zellij-server/src/panes/unit/grid_tests.rs index d5ce6a6a..59956f8e 100644 --- a/zellij-server/src/panes/unit/grid_tests.rs +++ b/zellij-server/src/panes/unit/grid_tests.rs @@ -4860,3 +4860,91 @@ fn highlight_style_variants_resolve_colors() { assert!(fg.is_some()); assert!(bg.is_none()); } + +fn create_grid_with_scrollback() -> Grid { + let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default())); + let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new())); + let mut grid = Grid::new( + 5, + 40, + Rc::new(RefCell::new(Palette::default())), + terminal_emulator_color_codes, + Rc::new(RefCell::new(LinkHandler::new())), + Rc::new(RefCell::new(None)), + sixel_image_store, + Style::default(), + false, + true, + true, + true, + false, + ); + let mut parser = vte::Parser::new(); + for i in 0..25 { + let line = format!("scrollback line {}\r\n", i); + for byte in line.as_bytes() { + parser.advance(&mut grid, *byte); + } + } + grid +} + +#[test] +fn pane_contents_scrollback_no_truncation_when_max_none() { + let grid = create_grid_with_scrollback(); + let result = grid.pane_contents(true, None); + assert_eq!( + result.lines_above_viewport.len(), + 21, + "All scrollback lines should be returned when max is None" + ); +} + +#[test] +fn pane_contents_scrollback_no_truncation_when_max_zero() { + let grid = create_grid_with_scrollback(); + let result = grid.pane_contents(true, Some(0)); + assert_eq!( + result.lines_above_viewport.len(), + 21, + "Some(0) is sentinel for all scrollback — no truncation" + ); +} + +#[test] +fn pane_contents_scrollback_truncates_to_last_n() { + let grid = create_grid_with_scrollback(); + let result = grid.pane_contents(true, Some(5)); + assert_eq!(result.lines_above_viewport.len(), 5); + let full = grid.pane_contents(true, None); + let expected: Vec<String> = full + .lines_above_viewport + .iter() + .rev() + .take(5) + .rev() + .cloned() + .collect(); + assert_eq!(result.lines_above_viewport, expected); +} + +#[test] +fn pane_contents_scrollback_no_truncation_when_n_exceeds_total() { + let grid = create_grid_with_scrollback(); + let result = grid.pane_contents(true, Some(100)); + assert_eq!( + result.lines_above_viewport.len(), + 21, + "No truncation when N exceeds total scrollback lines" + ); +} + +#[test] +fn pane_contents_no_scrollback_when_flag_false() { + let grid = create_grid_with_scrollback(); + let result = grid.pane_contents(false, Some(5)); + assert!( + result.lines_above_viewport.is_empty(), + "get_full_scrollback=false should never collect scrollback" + ); +} diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs index 5a5c5c15..540f398f 100644 --- a/zellij-server/src/route.rs +++ b/zellij-server/src/route.rs @@ -2289,6 +2289,21 @@ pub(crate) fn route_thread_main( let _ = to_server.send(ServerInstruction::FailedToStartWebServer(error)); }, + ClientToServerMsg::SubscribeToPaneRenders { + ref pane_ids, + ref scrollback, + } => { + send_to_screen_or_retry_queue!( + senders, + ScreenInstruction::SubscribeToPaneRenders { + client_id, + pane_ids: pane_ids.clone(), + scrollback: *scrollback, + }, + instruction, + retry_queue + ); + }, } Ok(should_break) }; diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index b84c2058..c907cfd6 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -42,9 +42,9 @@ use log::{debug, warn}; use zellij_utils::data::{ CommandOrPlugin, Direction, FloatingPaneCoordinates, GetFocusedPaneInfoResponse, KeyWithModifier, LayoutInfo, LayoutWithError, ListPanesResponse, ListTabsResponse, - NewPanePlacement, PaneContents, PaneInfo, PaneListEntry, PaneManifest, PaneScrollbackResponse, - PluginPermission, RegexHighlight, Resize, ResizeStrategy, SessionInfo, Styling, TabInfo, - WebSharing, + NewPanePlacement, PaneContents, PaneInfo, PaneListEntry, PaneManifest, PaneRenderReport, + PaneScrollbackResponse, PluginPermission, RegexHighlight, Resize, ResizeStrategy, SessionInfo, + Styling, TabInfo, WebSharing, }; use zellij_utils::errors::prelude::*; use zellij_utils::input::command::RunCommand; @@ -52,6 +52,7 @@ use zellij_utils::input::config::Config; use zellij_utils::input::keybinds::Keybinds; use zellij_utils::input::mouse::MouseEvent; use zellij_utils::input::options::Clipboard; +use zellij_utils::ipc::{ExitReason, ServerToClientMsg}; use zellij_utils::pane_size::{PaneGeom, Size, SizeInPixels}; use zellij_utils::shared::clean_string_from_control_and_linebreak; use zellij_utils::{ @@ -88,7 +89,7 @@ use zellij_utils::{ data::{Event, InputMode, ModeInfo, Palette, PaletteColor, PluginCapabilities, Style}, errors::{ContextType, ScreenContext}, input::get_mode_info, - ipc::{ClientAttributes, PixelDimensions, ServerToClientMsg}, + ipc::{ClientAttributes, PixelDimensions}, }; /// Get the active tab and call a closure on it @@ -681,6 +682,14 @@ pub enum ScreenInstruction { plugin_id: u32, }, ClearAllPluginHighlights(u32), // plugin_id — clears across all panes + SubscribeToPaneRenders { + client_id: ClientId, + pane_ids: Vec<zellij_utils::data::PaneId>, + scrollback: Option<usize>, + }, + NotifyPaneClosedToSubscribers { + pane_id: zellij_utils::data::PaneId, + }, } impl From<&ScreenInstruction> for ScreenContext { @@ -949,6 +958,12 @@ impl From<&ScreenInstruction> for ScreenContext { }, ScreenInstruction::ClearPluginHighlights { .. } => ScreenContext::ClearPluginHighlights, ScreenInstruction::ClearAllPluginHighlights(..) => ScreenContext::ClearPluginHighlights, + ScreenInstruction::SubscribeToPaneRenders { .. } => { + ScreenContext::SubscribeToPaneRenders + }, + ScreenInstruction::NotifyPaneClosedToSubscribers { .. } => { + ScreenContext::NotifyPaneClosedToSubscribers + }, } } } @@ -1070,6 +1085,11 @@ impl WatcherState { } } +struct PaneRenderSubscription { + pane_ids: HashSet<zellij_utils::data::PaneId>, + previous_viewports: HashMap<zellij_utils::data::PaneId, Vec<String>>, +} + /// A [`Screen`] holds multiple [`Tab`]s, each one holding multiple [`panes`](crate::client::panes). /// It only directly controls which tab is active, delegating the rest to the individual `Tab`. pub(crate) struct Screen { @@ -1134,6 +1154,7 @@ pub(crate) struct Screen { followed_client_id: Option<ClientId>, cached_layouts: Vec<LayoutInfo>, cached_layout_errors: Vec<LayoutWithError>, + pane_render_subscribers: HashMap<ClientId, PaneRenderSubscription>, } impl Screen { @@ -1228,6 +1249,7 @@ impl Screen { followed_client_id: None, cached_layouts: vec![], cached_layout_errors: vec![], + pane_render_subscribers: HashMap::new(), } } @@ -1609,6 +1631,11 @@ impl Screen { .collect(), )); + // Notify pane render subscribers of each closed pane + for p_id in &pane_ids { + self.notify_pane_closed_to_subscribers((*p_id).into()); + } + // below we don't check the result of sending the CloseTab instruction to the pty thread // because this might be happening when the app is closing, at which point the pty thread // has already closed and this would result in an error @@ -1784,6 +1811,12 @@ impl Screen { } let pane_render_report = output.drain_pane_render_report(); + + // Subscriber delivery — gated behind is_empty() for zero overhead + if !self.pane_render_subscribers.is_empty() { + self.deliver_to_pane_subscribers_from_report(&pane_render_report); + } + let _ = self .bus .senders @@ -1859,6 +1892,11 @@ impl Screen { } else { // No regular clients, output is not dirty non_watcher_output_was_dirty = false; + + // No regular clients but subscribers exist — query panes directly + if !self.pane_render_subscribers.is_empty() { + self.deliver_to_pane_subscribers_directly(); + } } // === PHASE 2: Render for watchers === @@ -2375,6 +2413,7 @@ impl Screen { self.tab_history.remove(&client_id); } self.connected_clients.borrow_mut().remove(&client_id); + self.pane_render_subscribers.remove(&client_id); self.log_and_report_session_state() .with_context(err_context) } @@ -4231,6 +4270,206 @@ impl Screen { } } } + fn subscribe_to_pane_renders( + &mut self, + subscriber_client_id: ClientId, + pane_ids: Vec<zellij_utils::data::PaneId>, + scrollback: Option<usize>, + ) { + let mut previous_viewports = HashMap::new(); + let mut valid_pane_ids = HashSet::new(); + + // Get a regular client ID for plugin pane content queries + let regular_client_id = self + .connected_clients + .borrow() + .keys() + .find(|id| !self.watcher_clients.contains_key(id)) + .copied(); + + for pane_id in &pane_ids { + let server_pane_id: PaneId = (*pane_id).into(); + let mut found = false; + + for tab in self.tabs.values() { + if let Some(pane) = tab.get_pane_with_id(server_pane_id) { + let get_full_scrollback = scrollback.is_some(); + let max_lines = scrollback.and_then(|n| if n == 0 { None } else { Some(n) }); + + // For plugin panes, use a regular client_id; for terminal panes, None is fine + let query_client_id = match server_pane_id { + PaneId::Plugin(_) => regular_client_id, + PaneId::Terminal(_) => None, + }; + let contents = + pane.pane_contents(query_client_id, get_full_scrollback, max_lines); + + if let Some(os_input) = &self.bus.os_input { + let scrollback_data = if scrollback.is_some() { + Some(contents.lines_above_viewport.clone()) + } else { + None + }; + let _ = os_input.send_to_client( + subscriber_client_id, + ServerToClientMsg::PaneRenderUpdate { + pane_id: *pane_id, + viewport: contents.viewport.clone(), + scrollback: scrollback_data, + is_initial: true, + }, + ); + } + + previous_viewports.insert(*pane_id, contents.viewport); + valid_pane_ids.insert(*pane_id); + found = true; + break; + } + } + + if !found { + if let Some(os_input) = &self.bus.os_input { + let _ = os_input.send_to_client( + subscriber_client_id, + ServerToClientMsg::LogError { + lines: vec![format!("Pane {} not found", pane_id)], + }, + ); + } + } + } + + if !valid_pane_ids.is_empty() { + self.pane_render_subscribers.insert( + subscriber_client_id, + PaneRenderSubscription { + pane_ids: valid_pane_ids, + previous_viewports, + }, + ); + } + } + fn deliver_to_pane_subscribers_from_report(&mut self, report: &PaneRenderReport) { + let Some(pane_map) = report.all_pane_contents.values().next() else { + return; + }; + self.deliver_subscriber_updates_from_map(pane_map); + } + fn deliver_to_pane_subscribers_directly(&mut self) { + // Collect unique pane IDs across all subscribers + let all_subscribed_ids: HashSet<zellij_utils::data::PaneId> = self + .pane_render_subscribers + .values() + .flat_map(|sub| sub.pane_ids.iter().copied()) + .collect(); + + // Query pane contents directly from tabs + let mut pane_map: HashMap<zellij_utils::data::PaneId, PaneContents> = HashMap::new(); + for pane_id in &all_subscribed_ids { + let server_pane_id: PaneId = (*pane_id).into(); + for tab in self.tabs.values() { + if let Some(pane) = tab.get_pane_with_id(server_pane_id) { + pane_map.insert(*pane_id, pane.pane_contents(None, false, None)); + break; + } + } + } + + self.deliver_subscriber_updates_from_map(&pane_map); + } + fn deliver_subscriber_updates_from_map( + &mut self, + pane_map: &HashMap<zellij_utils::data::PaneId, PaneContents>, + ) { + // Collect updates to send, avoiding borrow conflicts + let mut updates_to_send: Vec<(ClientId, ServerToClientMsg)> = Vec::new(); + let mut dead_subscribers: Vec<ClientId> = Vec::new(); + + for (subscriber_id, subscription) in &self.pane_render_subscribers { + for pane_id in &subscription.pane_ids { + if let Some(contents) = pane_map.get(pane_id) { + let changed = subscription + .previous_viewports + .get(pane_id) + .map(|prev| prev != &contents.viewport) + .unwrap_or(true); + + if changed { + updates_to_send.push(( + *subscriber_id, + ServerToClientMsg::PaneRenderUpdate { + pane_id: *pane_id, + viewport: contents.viewport.clone(), + scrollback: None, + is_initial: false, + }, + )); + } + } + } + } + + // Send updates and track dead subscribers + for (subscriber_id, msg) in &updates_to_send { + if let Some(os_input) = &self.bus.os_input { + if os_input + .send_to_client(*subscriber_id, msg.clone()) + .is_err() + { + dead_subscribers.push(*subscriber_id); + } + } + } + + // Update previous viewports for successful sends + for (subscriber_id, msg) in updates_to_send { + if dead_subscribers.contains(&subscriber_id) { + continue; + } + if let ServerToClientMsg::PaneRenderUpdate { + pane_id, viewport, .. + } = msg + { + if let Some(subscription) = self.pane_render_subscribers.get_mut(&subscriber_id) { + subscription.previous_viewports.insert(pane_id, viewport); + } + } + } + + for id in dead_subscribers { + self.pane_render_subscribers.remove(&id); + } + } + fn notify_pane_closed_to_subscribers(&mut self, pane_id: zellij_utils::data::PaneId) { + let mut dead_subscribers = Vec::new(); + + for (subscriber_id, subscription) in &mut self.pane_render_subscribers { + if subscription.pane_ids.remove(&pane_id) { + if let Some(os_input) = &self.bus.os_input { + let _ = os_input.send_to_client( + *subscriber_id, + ServerToClientMsg::SubscribedPaneClosed { pane_id }, + ); + } + if subscription.pane_ids.is_empty() { + if let Some(os_input) = &self.bus.os_input { + let _ = os_input.send_to_client( + *subscriber_id, + ServerToClientMsg::Exit { + exit_reason: ExitReason::Normal, + }, + ); + } + dead_subscribers.push(*subscriber_id); + } + } + } + + for id in dead_subscribers { + self.pane_render_subscribers.remove(&id); + } + } } #[cfg(not(test))] @@ -5042,7 +5281,7 @@ pub(crate) fn screen_thread_main( for tab in screen.get_tabs_mut().values() { if let Some(pane) = tab.get_pane_with_id(pane_id) { pane_contents = - Some(pane.pane_contents(Some(client_id), get_full_scrollback)); + Some(pane.pane_contents(Some(client_id), get_full_scrollback, None)); break; } } @@ -7695,6 +7934,16 @@ pub(crate) fn screen_thread_main( } screen.render(None)?; }, + ScreenInstruction::SubscribeToPaneRenders { + client_id, + pane_ids, + scrollback, + } => { + screen.subscribe_to_pane_renders(client_id, pane_ids, scrollback); + }, + ScreenInstruction::NotifyPaneClosedToSubscribers { pane_id } => { + screen.notify_pane_closed_to_subscribers(pane_id); + }, } } Ok(()) diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index 59a79126..7ade6b68 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -28,7 +28,7 @@ use zellij_utils::shared::clean_string_from_control_and_linebreak; use crate::background_jobs::BackgroundJob; use crate::pane_groups::PaneGroups; use crate::pty_writer::PtyWriteInstruction; -use crate::screen::CopyOptions; +use crate::screen::{CopyOptions, ScreenInstruction}; use crate::ui::{loading_indication::LoadingIndication, pane_boundaries_frame::FrameParams}; use layout_applier::LayoutApplier; use swap_layouts::SwapLayouts; @@ -604,6 +604,7 @@ pub trait Pane { &self, client_id: Option<ClientId>, _get_full_scrollback: bool, + _max_scrollback_lines: Option<usize>, ) -> PaneContents; fn update_exit_status(&mut self, _exit_status: i32) {} fn set_plugin_regex_highlights( @@ -2249,6 +2250,11 @@ impl Tab { None, Event::PaneClosed(pane_id.into()), )])); + let _ = self + .senders + .send_to_screen(ScreenInstruction::NotifyPaneClosedToSubscribers { + pane_id: pane_id.into(), + }); drop(replaced_pane); } } @@ -3817,6 +3823,11 @@ impl Tab { None, Event::PaneClosed(id.into()), )])); + let _ = self + .senders + .send_to_screen(ScreenInstruction::NotifyPaneClosedToSubscribers { + pane_id: id.into(), + }); } pub fn extract_pane( &mut self, diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs index 3d021f43..2536ee85 100644 --- a/zellij-server/src/unit/screen_tests.rs +++ b/zellij-server/src/unit/screen_tests.rs @@ -49,8 +49,10 @@ use crate::panes::grid::Grid; use crate::panes::link_handler::LinkHandler; use crate::panes::sixel::SixelImageStore; use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; +use zellij_utils::data::{PaneContents, PaneRenderReport}; +use zellij_utils::ipc::ExitReason; fn take_snapshot_and_cursor_coordinates( ansi_instructions: &str, @@ -361,7 +363,7 @@ impl MockScreen { let client_attributes = self.client_attributes.clone(); let screen_bus = Bus::new( vec![self.screen_receiver.take().unwrap()], - None, + Some(&self.to_screen.clone()), Some(&self.to_pty.clone()), Some(&self.to_plugin.clone()), Some(&self.to_server.clone()), @@ -447,7 +449,7 @@ impl MockScreen { let client_attributes = self.client_attributes.clone(); let screen_bus = Bus::new( vec![self.screen_receiver.take().unwrap()], - None, + Some(&self.to_screen.clone()), Some(&self.to_pty.clone()), Some(&self.to_plugin.clone()), Some(&self.to_server.clone()), @@ -5130,3 +5132,954 @@ pub fn send_cli_launch_or_focus_plugin_in_place_with_close_replaced_pane() { assert_snapshot!(format!("{:#?}", fill_plugin_cwd_instruction)); } + +fn create_new_screen_with_message_capture( + size: Size, +) -> ( + Screen, + Arc<Mutex<HashMap<ClientId, Vec<ServerToClientMsg>>>>, +) { + let mut bus: Bus<ScreenInstruction> = Bus::empty(); + let fake_os_input = FakeInputOutput::default(); + let messages = fake_os_input.server_to_client_messages.clone(); + bus.os_input = Some(Box::new(fake_os_input)); + let client_attributes = ClientAttributes { + size, + ..Default::default() + }; + let max_panes = None; + let mut mode_info = ModeInfo::default(); + mode_info.session_name = Some("zellij-test".into()); + let draw_pane_frames = false; + let auto_layout = true; + let session_is_mirrored = true; + let copy_options = CopyOptions::default(); + let default_layout = Box::new(Layout::default()); + let default_layout_name = None; + let default_shell = PathBuf::from("my_default_shell"); + let session_serialization = true; + let serialize_pane_viewport = false; + let scrollback_lines_to_serialize = None; + let layout_dir = None; + let debug = false; + let styled_underlines = true; + let osc8_hyperlinks = true; + let arrow_fonts = true; + let explicitly_disable_kitty_keyboard_protocol = false; + let stacked_resize = true; + let web_sharing = WebSharing::Off; + let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); + let web_server_port = 8080; + let visual_bell = true; + let screen = Screen::new( + bus, + &client_attributes, + max_panes, + mode_info, + draw_pane_frames, + auto_layout, + session_is_mirrored, + copy_options, + debug, + default_layout, + default_layout_name, + default_shell, + session_serialization, + serialize_pane_viewport, + scrollback_lines_to_serialize, + styled_underlines, + osc8_hyperlinks, + arrow_fonts, + layout_dir, + explicitly_disable_kitty_keyboard_protocol, + stacked_resize, + None, + false, + web_sharing, + true, + true, + visual_bell, + web_server_ip, + web_server_port, + ); + (screen, messages) +} + +#[test] +fn subscriber_receives_initial_delivery() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, messages) = create_new_screen_with_message_capture(size); + new_tab(&mut screen, 1, 0); + + screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None); + + let msgs = messages.lock().unwrap(); + let client_msgs = msgs.get(&100).unwrap(); + assert_eq!(client_msgs.len(), 1); + match &client_msgs[0] { + ServerToClientMsg::PaneRenderUpdate { + is_initial, + scrollback, + .. + } => { + assert!(*is_initial); + assert!(scrollback.is_none()); + }, + other => panic!("Expected PaneRenderUpdate, got {:?}", other), + } +} + +#[test] +fn subscriber_receives_initial_with_scrollback() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, messages) = create_new_screen_with_message_capture(size); + new_tab(&mut screen, 1, 0); + + screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], Some(0)); + + let msgs = messages.lock().unwrap(); + let client_msgs = msgs.get(&100).unwrap(); + assert_eq!(client_msgs.len(), 1); + match &client_msgs[0] { + ServerToClientMsg::PaneRenderUpdate { + is_initial, + scrollback, + .. + } => { + assert!(*is_initial); + assert!(scrollback.is_some()); + }, + other => panic!("Expected PaneRenderUpdate, got {:?}", other), + } +} + +#[test] +fn subscriber_no_update_on_unchanged_viewport() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, messages) = create_new_screen_with_message_capture(size); + new_tab(&mut screen, 1, 0); + + screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None); + + let initial_viewport = { + let msgs = messages.lock().unwrap(); + let client_msgs = msgs.get(&100).unwrap(); + match &client_msgs[0] { + ServerToClientMsg::PaneRenderUpdate { viewport, .. } => viewport.clone(), + _ => panic!("Expected PaneRenderUpdate"), + } + }; + + let mut pane_map = HashMap::new(); + pane_map.insert( + zellij_utils::data::PaneId::Terminal(1), + PaneContents { + viewport: initial_viewport, + ..Default::default() + }, + ); + screen.deliver_subscriber_updates_from_map(&pane_map); + + let msgs = messages.lock().unwrap(); + let client_msgs = msgs.get(&100).unwrap(); + assert_eq!(client_msgs.len(), 1); +} + +#[test] +fn subscriber_receives_update_on_changed_viewport() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, messages) = create_new_screen_with_message_capture(size); + new_tab(&mut screen, 1, 0); + + screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None); + + let mut pane_map = HashMap::new(); + pane_map.insert( + zellij_utils::data::PaneId::Terminal(1), + PaneContents { + viewport: vec!["changed line".to_string()], + ..Default::default() + }, + ); + screen.deliver_subscriber_updates_from_map(&pane_map); + + let msgs = messages.lock().unwrap(); + let client_msgs = msgs.get(&100).unwrap(); + assert_eq!(client_msgs.len(), 2); + match &client_msgs[1] { + ServerToClientMsg::PaneRenderUpdate { + is_initial, + scrollback, + viewport, + .. + } => { + assert!(!is_initial); + assert!(scrollback.is_none()); + assert_eq!(viewport, &vec!["changed line".to_string()]); + }, + other => panic!("Expected PaneRenderUpdate, got {:?}", other), + } +} + +#[test] +fn subscriber_error_for_nonexistent_pane() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, messages) = create_new_screen_with_message_capture(size); + new_tab(&mut screen, 1, 0); + + screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(999)], None); + + let msgs = messages.lock().unwrap(); + let client_msgs = msgs.get(&100).unwrap(); + assert_eq!(client_msgs.len(), 1); + match &client_msgs[0] { + ServerToClientMsg::LogError { lines } => { + let joined = lines.join(" "); + assert!( + joined.contains("not found"), + "Error message should contain 'not found', got: {}", + joined + ); + }, + other => panic!("Expected LogError, got {:?}", other), + } + assert!( + !screen.pane_render_subscribers.contains_key(&100), + "Subscriber should not be registered for nonexistent pane" + ); +} + +#[test] +fn subscriber_state_registered_for_multiple_panes() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, _messages) = create_new_screen_with_message_capture(size); + new_tab(&mut screen, 1, 0); + new_tab(&mut screen, 2, 1); + + screen.subscribe_to_pane_renders( + 100, + vec![ + zellij_utils::data::PaneId::Terminal(1), + zellij_utils::data::PaneId::Terminal(2), + ], + None, + ); + + let sub = screen.pane_render_subscribers.get(&100).unwrap(); + assert_eq!(sub.pane_ids.len(), 2); + assert!(sub + .pane_ids + .contains(&zellij_utils::data::PaneId::Terminal(1))); + assert!(sub + .pane_ids + .contains(&zellij_utils::data::PaneId::Terminal(2))); +} + +#[test] +fn multiple_subscribers_receive_updates() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, messages) = create_new_screen_with_message_capture(size); + new_tab(&mut screen, 1, 0); + + screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None); + screen.subscribe_to_pane_renders(101, vec![zellij_utils::data::PaneId::Terminal(1)], None); + + let mut pane_map = HashMap::new(); + pane_map.insert( + zellij_utils::data::PaneId::Terminal(1), + PaneContents { + viewport: vec!["new content".to_string()], + ..Default::default() + }, + ); + screen.deliver_subscriber_updates_from_map(&pane_map); + + let msgs = messages.lock().unwrap(); + let client_100_msgs = msgs.get(&100).unwrap(); + let client_101_msgs = msgs.get(&101).unwrap(); + assert_eq!(client_100_msgs.len(), 2); + assert_eq!(client_101_msgs.len(), 2); +} + +#[test] +fn subscriber_removed_on_remove_client() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, _messages) = create_new_screen_with_message_capture(size); + new_tab(&mut screen, 1, 0); + + screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None); + assert!(screen.pane_render_subscribers.contains_key(&100)); + + let _ = screen.remove_client(100); + assert!(!screen.pane_render_subscribers.contains_key(&100)); +} + +#[test] +fn subscriber_removed_when_all_panes_closed() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, messages) = create_new_screen_with_message_capture(size); + new_tab(&mut screen, 1, 0); + + screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None); + + screen.notify_pane_closed_to_subscribers(zellij_utils::data::PaneId::Terminal(1)); + + let msgs = messages.lock().unwrap(); + let client_msgs = msgs.get(&100).unwrap(); + let has_pane_closed = client_msgs.iter().any(|m| { + matches!( + m, + ServerToClientMsg::SubscribedPaneClosed { + pane_id: zellij_utils::data::PaneId::Terminal(1), + } + ) + }); + let has_exit = client_msgs.iter().any(|m| { + matches!( + m, + ServerToClientMsg::Exit { + exit_reason: ExitReason::Normal, + } + ) + }); + assert!(has_pane_closed, "Should send SubscribedPaneClosed"); + assert!(has_exit, "Should send Exit when all panes closed"); + assert!(!screen.pane_render_subscribers.contains_key(&100)); +} + +#[test] +fn subscriber_partial_close() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, messages) = create_new_screen_with_message_capture(size); + new_tab(&mut screen, 1, 0); + new_tab(&mut screen, 2, 1); + + screen.subscribe_to_pane_renders( + 100, + vec![ + zellij_utils::data::PaneId::Terminal(1), + zellij_utils::data::PaneId::Terminal(2), + ], + None, + ); + + screen.notify_pane_closed_to_subscribers(zellij_utils::data::PaneId::Terminal(1)); + + let msgs = messages.lock().unwrap(); + let client_msgs = msgs.get(&100).unwrap(); + let has_pane_closed = client_msgs.iter().any(|m| { + matches!( + m, + ServerToClientMsg::SubscribedPaneClosed { + pane_id: zellij_utils::data::PaneId::Terminal(1), + } + ) + }); + let has_exit = client_msgs + .iter() + .any(|m| matches!(m, ServerToClientMsg::Exit { .. })); + assert!( + has_pane_closed, + "Should send SubscribedPaneClosed for closed pane" + ); + assert!(!has_exit, "Should NOT send Exit when panes remain"); + assert!(screen.pane_render_subscribers.contains_key(&100)); + assert_eq!( + screen + .pane_render_subscribers + .get(&100) + .unwrap() + .pane_ids + .len(), + 1 + ); +} + +#[test] +fn subscriber_full_close_sequence() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, messages) = create_new_screen_with_message_capture(size); + new_tab(&mut screen, 1, 0); + new_tab(&mut screen, 2, 1); + + screen.subscribe_to_pane_renders( + 100, + vec![ + zellij_utils::data::PaneId::Terminal(1), + zellij_utils::data::PaneId::Terminal(2), + ], + None, + ); + + screen.notify_pane_closed_to_subscribers(zellij_utils::data::PaneId::Terminal(1)); + screen.notify_pane_closed_to_subscribers(zellij_utils::data::PaneId::Terminal(2)); + + let msgs = messages.lock().unwrap(); + let client_msgs = msgs.get(&100).unwrap(); + let pane_closed_count = client_msgs + .iter() + .filter(|m| matches!(m, ServerToClientMsg::SubscribedPaneClosed { .. })) + .count(); + let exit_count = client_msgs + .iter() + .filter(|m| { + matches!( + m, + ServerToClientMsg::Exit { + exit_reason: ExitReason::Normal, + } + ) + }) + .count(); + assert_eq!(pane_closed_count, 2, "Two SubscribedPaneClosed messages"); + assert_eq!(exit_count, 1, "One Exit message after all panes closed"); + assert!(!screen.pane_render_subscribers.contains_key(&100)); +} + +#[test] +fn delivery_path_a_and_b_produce_same_content() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, messages) = create_new_screen_with_message_capture(size); + new_tab(&mut screen, 1, 0); + + screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None); + + messages.lock().unwrap().get_mut(&100).unwrap().clear(); + + let contents = { + let server_pane_id = PaneId::Terminal(1); + let mut found_contents = None; + for tab in screen.tabs.values() { + if let Some(pane) = tab.get_pane_with_id(server_pane_id) { + found_contents = Some(pane.pane_contents(None, false, None)); + break; + } + } + found_contents.expect("Pane should exist") + }; + + screen + .pane_render_subscribers + .get_mut(&100) + .unwrap() + .previous_viewports + .insert( + zellij_utils::data::PaneId::Terminal(1), + vec!["old".to_string()], + ); + + let mut pane_map = HashMap::new(); + pane_map.insert(zellij_utils::data::PaneId::Terminal(1), contents.clone()); + let mut all_pane_contents = HashMap::new(); + all_pane_contents.insert(1 as ClientId, pane_map); + let report = PaneRenderReport { all_pane_contents }; + screen.deliver_to_pane_subscribers_from_report(&report); + + let viewport_a = { + let msgs = messages.lock().unwrap(); + let client_msgs = msgs.get(&100).unwrap(); + match &client_msgs[0] { + ServerToClientMsg::PaneRenderUpdate { viewport, .. } => viewport.clone(), + other => panic!("Expected PaneRenderUpdate, got {:?}", other), + } + }; + + messages.lock().unwrap().get_mut(&100).unwrap().clear(); + screen + .pane_render_subscribers + .get_mut(&100) + .unwrap() + .previous_viewports + .insert( + zellij_utils::data::PaneId::Terminal(1), + vec!["old".to_string()], + ); + + screen.deliver_to_pane_subscribers_directly(); + + let viewport_b = { + let msgs = messages.lock().unwrap(); + let client_msgs = msgs.get(&100).unwrap(); + match &client_msgs[0] { + ServerToClientMsg::PaneRenderUpdate { viewport, .. } => viewport.clone(), + other => panic!("Expected PaneRenderUpdate, got {:?}", other), + } + }; + + assert_eq!( + viewport_a, viewport_b, + "Both delivery paths should produce identical viewport" + ); +} + +#[test] +fn close_tab_notifies_subscribers() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, messages) = create_new_screen_with_message_capture(size); + new_tab(&mut screen, 1, 0); + new_tab(&mut screen, 2, 1); + + screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None); + + messages.lock().unwrap().get_mut(&100).unwrap().clear(); + + let _ = screen.go_to_tab(1, 1); + let _ = screen.close_tab(1); + + let msgs = messages.lock().unwrap(); + let client_msgs = msgs.get(&100).unwrap(); + let has_pane_closed = client_msgs.iter().any(|m| { + matches!( + m, + ServerToClientMsg::SubscribedPaneClosed { + pane_id: zellij_utils::data::PaneId::Terminal(1), + } + ) + }); + assert!( + has_pane_closed, + "Closing tab should notify subscriber of pane closure" + ); +} + +#[test] +fn close_pane_notifies_subscribers_via_instruction() { + let size = Size { cols: 80, rows: 20 }; + let mut mock_screen = MockScreen::new(size); + mock_screen.drop_all_pty_messages(); + let session_metadata = mock_screen.clone_session_metadata(); + + let mut initial_layout = TiledPaneLayout::default(); + initial_layout.children_split_direction = SplitDirection::Vertical; + initial_layout.children = vec![TiledPaneLayout::default(), TiledPaneLayout::default()]; + + let screen_thread = mock_screen.run(Some(initial_layout), vec![]); + + let server_receiver = mock_screen.server_receiver.take().unwrap(); + let received_server_instructions = Arc::new(Mutex::new(vec![])); + let server_thread = log_actions_in_thread!( + received_server_instructions, + ServerInstruction::KillSession, + server_receiver + ); + let plugin_receiver = mock_screen.plugin_receiver.take().unwrap(); + let received_plugin_instructions = Arc::new(Mutex::new(vec![])); + let plugin_thread = log_actions_in_thread!( + received_plugin_instructions, + PluginInstruction::Exit, + plugin_receiver + ); + + let _ = mock_screen + .to_screen + .send(ScreenInstruction::SubscribeToPaneRenders { + client_id: 100, + pane_ids: vec![ + zellij_utils::data::PaneId::Terminal(0), + zellij_utils::data::PaneId::Terminal(1), + ], + scrollback: None, + }); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let _ = mock_screen + .to_screen + .send(ScreenInstruction::CloseFocusedPane(1, None)); + std::thread::sleep(std::time::Duration::from_millis(200)); + + mock_screen.teardown(vec![server_thread, plugin_thread, screen_thread]); + + let msgs = mock_screen + .os_input + .server_to_client_messages + .lock() + .unwrap(); + let client_msgs = msgs.get(&100).unwrap_or(&vec![]).clone(); + let has_pane_closed = client_msgs + .iter() + .any(|m| matches!(m, ServerToClientMsg::SubscribedPaneClosed { .. })); + assert!( + has_pane_closed, + "Closing pane via CLI action should notify subscriber. Messages: {:?}", + client_msgs + ); +} + +#[test] +fn integration_pty_bytes_delivered_to_subscriber() { + let size = Size { cols: 80, rows: 20 }; + let mut mock_screen = MockScreen::new(size); + mock_screen.drop_all_pty_messages(); + let screen_thread = mock_screen.run(None, vec![]); + + let server_receiver = mock_screen.server_receiver.take().unwrap(); + let received_server_instructions = Arc::new(Mutex::new(vec![])); + let server_thread = log_actions_in_thread!( + received_server_instructions, + ServerInstruction::KillSession, + server_receiver + ); + let plugin_receiver = mock_screen.plugin_receiver.take().unwrap(); + let received_plugin_instructions = Arc::new(Mutex::new(vec![])); + let plugin_thread = log_actions_in_thread!( + received_plugin_instructions, + PluginInstruction::Exit, + plugin_receiver + ); + + let _ = mock_screen + .to_screen + .send(ScreenInstruction::SubscribeToPaneRenders { + client_id: 100, + pane_ids: vec![zellij_utils::data::PaneId::Terminal(0)], + scrollback: None, + }); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let _ = mock_screen.to_screen.send(ScreenInstruction::PtyBytes( + 0, + "hello world\r\n".as_bytes().to_vec(), + )); + std::thread::sleep(std::time::Duration::from_millis(100)); + + mock_screen.teardown(vec![server_thread, plugin_thread, screen_thread]); + + let msgs = mock_screen + .os_input + .server_to_client_messages + .lock() + .unwrap(); + let subscriber_msgs = msgs.get(&100).unwrap_or(&vec![]).clone(); + + assert!( + subscriber_msgs.len() >= 2, + "Should have at least initial + update, got {}", + subscriber_msgs.len() + ); + + match &subscriber_msgs[0] { + ServerToClientMsg::PaneRenderUpdate { is_initial, .. } => { + assert!(*is_initial, "First message should be initial"); + }, + other => panic!("Expected PaneRenderUpdate, got {:?}", other), + } + + let has_hello = subscriber_msgs.iter().any(|m| match m { + ServerToClientMsg::PaneRenderUpdate { + is_initial: false, + viewport, + .. + } => viewport.iter().any(|line| line.contains("hello world")), + _ => false, + }); + assert!( + has_hello, + "Subsequent message should contain 'hello world' in viewport. Messages: {:?}", + subscriber_msgs + ); +} + +#[test] +fn integration_pty_bytes_not_delivered_when_viewport_unchanged() { + let size = Size { cols: 80, rows: 20 }; + let mut mock_screen = MockScreen::new(size); + mock_screen.drop_all_pty_messages(); + let screen_thread = mock_screen.run(None, vec![]); + + let server_receiver = mock_screen.server_receiver.take().unwrap(); + let received_server_instructions = Arc::new(Mutex::new(vec![])); + let server_thread = log_actions_in_thread!( + received_server_instructions, + ServerInstruction::KillSession, + server_receiver + ); + let plugin_receiver = mock_screen.plugin_receiver.take().unwrap(); + let received_plugin_instructions = Arc::new(Mutex::new(vec![])); + let plugin_thread = log_actions_in_thread!( + received_plugin_instructions, + PluginInstruction::Exit, + plugin_receiver + ); + + let _ = mock_screen + .to_screen + .send(ScreenInstruction::SubscribeToPaneRenders { + client_id: 100, + pane_ids: vec![zellij_utils::data::PaneId::Terminal(0)], + scrollback: None, + }); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let _ = mock_screen + .to_screen + .send(ScreenInstruction::RenderToClients); + std::thread::sleep(std::time::Duration::from_millis(100)); + + mock_screen.teardown(vec![server_thread, plugin_thread, screen_thread]); + + let msgs = mock_screen + .os_input + .server_to_client_messages + .lock() + .unwrap(); + let subscriber_msgs = msgs.get(&100).unwrap_or(&vec![]).clone(); + + let render_update_count = subscriber_msgs + .iter() + .filter(|m| matches!(m, ServerToClientMsg::PaneRenderUpdate { .. })) + .count(); + assert_eq!( + render_update_count, 1, + "Only the initial delivery should be present, not a duplicate. Got {} messages: {:?}", + render_update_count, subscriber_msgs + ); +} + +#[test] +fn integration_scrollback_from_pre_subscription_pty_bytes() { + let size = Size { cols: 80, rows: 20 }; + let mut mock_screen = MockScreen::new(size); + mock_screen.drop_all_pty_messages(); + let screen_thread = mock_screen.run(None, vec![]); + + let server_receiver = mock_screen.server_receiver.take().unwrap(); + let received_server_instructions = Arc::new(Mutex::new(vec![])); + let server_thread = log_actions_in_thread!( + received_server_instructions, + ServerInstruction::KillSession, + server_receiver + ); + let plugin_receiver = mock_screen.plugin_receiver.take().unwrap(); + let received_plugin_instructions = Arc::new(Mutex::new(vec![])); + let plugin_thread = log_actions_in_thread!( + received_plugin_instructions, + PluginInstruction::Exit, + plugin_receiver + ); + + let mut data = String::new(); + for i in 0..30 { + data.push_str(&format!("line {}\r\n", i)); + } + let _ = mock_screen + .to_screen + .send(ScreenInstruction::PtyBytes(0, data.as_bytes().to_vec())); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let _ = mock_screen + .to_screen + .send(ScreenInstruction::SubscribeToPaneRenders { + client_id: 100, + pane_ids: vec![zellij_utils::data::PaneId::Terminal(0)], + scrollback: Some(0), + }); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let _ = mock_screen.to_screen.send(ScreenInstruction::PtyBytes( + 0, + "post subscribe line\r\n".as_bytes().to_vec(), + )); + std::thread::sleep(std::time::Duration::from_millis(100)); + + mock_screen.teardown(vec![server_thread, plugin_thread, screen_thread]); + + let msgs = mock_screen + .os_input + .server_to_client_messages + .lock() + .unwrap(); + let subscriber_msgs = msgs.get(&100).unwrap_or(&vec![]).clone(); + + let initial_msg = subscriber_msgs.iter().find(|m| { + matches!( + m, + ServerToClientMsg::PaneRenderUpdate { + is_initial: true, + .. + } + ) + }); + assert!(initial_msg.is_some(), "Should have an initial message"); + match initial_msg.unwrap() { + ServerToClientMsg::PaneRenderUpdate { + scrollback, + is_initial, + .. + } => { + assert!(*is_initial); + assert!( + scrollback.is_some(), + "Initial message should include scrollback" + ); + let sb = scrollback.as_ref().unwrap(); + assert!( + !sb.is_empty(), + "Scrollback should contain pre-subscription lines" + ); + let has_early_lines = sb.iter().any(|line| line.contains("line 0")); + assert!( + has_early_lines, + "Scrollback should contain early lines. Got: {:?}", + sb + ); + }, + _ => unreachable!(), + } + + let subsequent_updates: Vec<_> = subscriber_msgs + .iter() + .filter(|m| { + matches!( + m, + ServerToClientMsg::PaneRenderUpdate { + is_initial: false, + .. + } + ) + }) + .collect(); + for msg in &subsequent_updates { + match msg { + ServerToClientMsg::PaneRenderUpdate { scrollback, .. } => { + assert!( + scrollback.is_none(), + "Subsequent updates should not include scrollback" + ); + }, + _ => unreachable!(), + } + } +} + +#[test] +fn integration_no_scrollback_when_not_requested() { + let size = Size { cols: 80, rows: 20 }; + let mut mock_screen = MockScreen::new(size); + mock_screen.drop_all_pty_messages(); + let screen_thread = mock_screen.run(None, vec![]); + + let server_receiver = mock_screen.server_receiver.take().unwrap(); + let received_server_instructions = Arc::new(Mutex::new(vec![])); + let server_thread = log_actions_in_thread!( + received_server_instructions, + ServerInstruction::KillSession, + server_receiver + ); + let plugin_receiver = mock_screen.plugin_receiver.take().unwrap(); + let received_plugin_instructions = Arc::new(Mutex::new(vec![])); + let plugin_thread = log_actions_in_thread!( + received_plugin_instructions, + PluginInstruction::Exit, + plugin_receiver + ); + + let mut data = String::new(); + for i in 0..30 { + data.push_str(&format!("line {}\r\n", i)); + } + let _ = mock_screen + .to_screen + .send(ScreenInstruction::PtyBytes(0, data.as_bytes().to_vec())); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let _ = mock_screen + .to_screen + .send(ScreenInstruction::SubscribeToPaneRenders { + client_id: 100, + pane_ids: vec![zellij_utils::data::PaneId::Terminal(0)], + scrollback: None, + }); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let _ = mock_screen.to_screen.send(ScreenInstruction::PtyBytes( + 0, + "post subscribe\r\n".as_bytes().to_vec(), + )); + std::thread::sleep(std::time::Duration::from_millis(100)); + + mock_screen.teardown(vec![server_thread, plugin_thread, screen_thread]); + + let msgs = mock_screen + .os_input + .server_to_client_messages + .lock() + .unwrap(); + let subscriber_msgs = msgs.get(&100).unwrap_or(&vec![]).clone(); + + for msg in &subscriber_msgs { + match msg { + ServerToClientMsg::PaneRenderUpdate { scrollback, .. } => { + assert!( + scrollback.is_none(), + "Scrollback should be None when not requested. Got: {:?}", + scrollback + ); + }, + _ => {}, + } + } +} + +#[test] +fn integration_subscriber_survives_after_regular_client_detach() { + let size = Size { cols: 80, rows: 20 }; + let mut mock_screen = MockScreen::new(size); + mock_screen.drop_all_pty_messages(); + let screen_thread = mock_screen.run(None, vec![]); + + let server_receiver = mock_screen.server_receiver.take().unwrap(); + let received_server_instructions = Arc::new(Mutex::new(vec![])); + let server_thread = log_actions_in_thread!( + received_server_instructions, + ServerInstruction::KillSession, + server_receiver + ); + let plugin_receiver = mock_screen.plugin_receiver.take().unwrap(); + let received_plugin_instructions = Arc::new(Mutex::new(vec![])); + let plugin_thread = log_actions_in_thread!( + received_plugin_instructions, + PluginInstruction::Exit, + plugin_receiver + ); + + let _ = mock_screen + .to_screen + .send(ScreenInstruction::SubscribeToPaneRenders { + client_id: 100, + pane_ids: vec![zellij_utils::data::PaneId::Terminal(0)], + scrollback: None, + }); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let _ = mock_screen + .to_screen + .send(ScreenInstruction::RemoveClient(1)); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let _ = mock_screen.to_screen.send(ScreenInstruction::PtyBytes( + 0, + "after detach\r\n".as_bytes().to_vec(), + )); + std::thread::sleep(std::time::Duration::from_millis(100)); + + mock_screen.teardown(vec![server_thread, plugin_thread, screen_thread]); + + let msgs = mock_screen + .os_input + .server_to_client_messages + .lock() + .unwrap(); + let subscriber_msgs = msgs.get(&100).unwrap_or(&vec![]).clone(); + + let has_after_detach = subscriber_msgs.iter().any(|m| match m { + ServerToClientMsg::PaneRenderUpdate { viewport, .. } => { + viewport.iter().any(|line| line.contains("after detach")) + }, + _ => false, + }); + assert!( + has_after_detach, + "Subscriber should receive updates after regular client detach. Messages: {:?}", + subscriber_msgs + ); +} diff --git a/zellij-utils/assets/prost_ipc/client_server_contract.rs b/zellij-utils/assets/prost_ipc/client_server_contract.rs index f2bc91c9..e7fdd27d 100644 --- a/zellij-utils/assets/prost_ipc/client_server_contract.rs +++ b/zellij-utils/assets/prost_ipc/client_server_contract.rs @@ -2583,7 +2583,7 @@ impl WebSharing { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ClientToServerMsg { - #[prost(oneof="client_to_server_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16")] + #[prost(oneof="client_to_server_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17")] pub message: ::core::option::Option<client_to_server_msg::Message>, } /// Nested message and enum types in `ClientToServerMsg`. @@ -2623,6 +2623,8 @@ pub mod client_to_server_msg { FailedToStartWebServer(super::FailedToStartWebServerMsg), #[prost(message, tag="16")] AttachWatcherClient(super::AttachWatcherClientMsg), + #[prost(message, tag="17")] + SubscribeToPaneRenders(super::SubscribeToPaneRendersMsg), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -2740,8 +2742,16 @@ pub struct FailedToStartWebServerMsg { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct SubscribeToPaneRendersMsg { + #[prost(message, repeated, tag="1")] + pub pane_ids: ::prost::alloc::vec::Vec<PaneId>, + #[prost(uint32, optional, tag="2")] + pub scrollback: ::core::option::Option<u32>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct ServerToClientMsg { - #[prost(oneof="server_to_client_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13")] + #[prost(oneof="server_to_client_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15")] pub message: ::core::option::Option<server_to_client_msg::Message>, } /// Nested message and enum types in `ServerToClientMsg`. @@ -2775,6 +2785,10 @@ pub mod server_to_client_msg { RenamedSession(super::RenamedSessionMsg), #[prost(message, tag="13")] ConfigFileUpdated(super::ConfigFileUpdatedMsg), + #[prost(message, tag="14")] + PaneRenderUpdate(super::PaneRenderUpdateMsg), + #[prost(message, tag="15")] + SubscribedPaneClosed(super::SubscribedPaneClosedMsg), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -2854,3 +2868,23 @@ pub struct RenamedSessionMsg { #[derive(Clone, PartialEq, ::prost::Message)] pub struct ConfigFileUpdatedMsg { } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PaneRenderUpdateMsg { + #[prost(message, optional, tag="1")] + pub pane_id: ::core::option::Option<PaneId>, + #[prost(string, repeated, tag="2")] + pub viewport: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(string, repeated, tag="3")] + pub scrollback: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(bool, tag="4")] + pub has_scrollback: bool, + #[prost(bool, tag="5")] + pub is_initial: bool, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SubscribedPaneClosedMsg { + #[prost(message, optional, tag="1")] + pub pane_id: ::core::option::Option<PaneId>, +} diff --git a/zellij-utils/src/cli.rs b/zellij-utils/src/cli.rs index 8ebbeca7..63c7777f 100644 --- a/zellij-utils/src/cli.rs +++ b/zellij-utils/src/cli.rs @@ -4,7 +4,7 @@ use crate::{ consts::{ZELLIJ_CONFIG_DIR_ENV, ZELLIJ_CONFIG_FILE_ENV}, input::{layout::PluginUserConfiguration, options::Options}, }; -use clap::{Args, Parser, Subcommand}; +use clap::{ArgEnum, Args, Parser, Subcommand}; use serde::{Deserialize, Serialize}; use std::net::IpAddr; use std::path::PathBuf; @@ -119,6 +119,46 @@ pub enum Command { /// Explore existing zellij sessions #[clap(flatten)] Sessions(Sessions), + + /// Subscribe to pane render updates (viewport and scrollback) + #[clap(override_usage( + "zellij [--session <OTHER SESSION NAME>] subscribe [OPTIONS] --pane-id..." + ))] + Subscribe(SubscribeCli), +} + +#[derive(Debug, Parser, Clone, Serialize, Deserialize)] +pub struct SubscribeCli { + /// Pane ID(s) to subscribe to (e.g. terminal_1, plugin_2, or bare number like 1) + #[clap( + short, + long, + required = true, + multiple_values = true, + multiple_occurrences = true + )] + pub pane_id: Vec<String>, + + /// Include scrollback lines in initial delivery. + /// Bare --scrollback = all scrollback, --scrollback N = last N lines. + #[clap( + short, + long, + min_values = 0, + max_values = 1, + default_missing_value = "0" + )] + pub scrollback: Option<usize>, + + /// Output format + #[clap(short, long, default_value = "raw", arg_enum)] + pub format: SubscribeFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ArgEnum)] +pub enum SubscribeFormat { + Raw, + Json, } #[derive(Debug, Clone, Args, Serialize, Deserialize)] @@ -1373,3 +1413,76 @@ tail -f /tmp/my-live-logfile | zellij action pipe --name logs --plugin https://e reset: bool, }, } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn parse_subscribe(args: &[&str]) -> SubscribeCli { + let mut full_args = vec!["zellij"]; + full_args.extend_from_slice(args); + let cli = CliArgs::try_parse_from(full_args).unwrap(); + match cli.command { + Some(Command::Subscribe(s)) => s, + other => panic!("Expected Subscribe, got {:?}", other), + } + } + + #[test] + fn subscribe_scrollback_bare_flag() { + let s = parse_subscribe(&["subscribe", "--pane-id", "terminal_1", "--scrollback"]); + assert_eq!(s.scrollback, Some(0)); + } + + #[test] + fn subscribe_scrollback_with_value() { + let s = parse_subscribe(&[ + "subscribe", + "--pane-id", + "terminal_1", + "--scrollback", + "100", + ]); + assert_eq!(s.scrollback, Some(100)); + } + + #[test] + fn subscribe_scrollback_absent() { + let s = parse_subscribe(&["subscribe", "--pane-id", "terminal_1"]); + assert_eq!(s.scrollback, None); + } + + #[test] + fn subscribe_format_json() { + let s = parse_subscribe(&["subscribe", "--pane-id", "terminal_1", "--format", "json"]); + assert!(matches!(s.format, SubscribeFormat::Json)); + } + + #[test] + fn subscribe_format_default_raw() { + let s = parse_subscribe(&["subscribe", "--pane-id", "terminal_1"]); + assert!(matches!(s.format, SubscribeFormat::Raw)); + } + + #[test] + fn subscribe_multiple_pane_ids() { + let s = parse_subscribe(&[ + "subscribe", + "--pane-id", + "terminal_1", + "--pane-id", + "plugin_2", + ]); + assert_eq!( + s.pane_id, + vec!["terminal_1".to_string(), "plugin_2".to_string()] + ); + } + + #[test] + fn subscribe_requires_pane_id() { + let result = CliArgs::try_parse_from(["zellij", "subscribe"]); + assert!(result.is_err()); + } +} diff --git a/zellij-utils/src/client_server_contract/client_to_server.proto b/zellij-utils/src/client_server_contract/client_to_server.proto index 7d922e6d..8fdedac2 100644 --- a/zellij-utils/src/client_server_contract/client_to_server.proto +++ b/zellij-utils/src/client_server_contract/client_to_server.proto @@ -21,6 +21,7 @@ message ClientToServerMsg { WebServerStartedMsg web_server_started = 14; FailedToStartWebServerMsg failed_to_start_web_server = 15; AttachWatcherClientMsg attach_watcher_client = 16; + SubscribeToPaneRendersMsg subscribe_to_pane_renders = 17; } } @@ -97,3 +98,8 @@ message WebServerStartedMsg { message FailedToStartWebServerMsg { string error = 1; } + +message SubscribeToPaneRendersMsg { + repeated PaneId pane_ids = 1; + optional uint32 scrollback = 2; +} diff --git a/zellij-utils/src/client_server_contract/server_to_client.proto b/zellij-utils/src/client_server_contract/server_to_client.proto index 57813ebb..e0b8b529 100644 --- a/zellij-utils/src/client_server_contract/server_to_client.proto +++ b/zellij-utils/src/client_server_contract/server_to_client.proto @@ -18,6 +18,8 @@ message ServerToClientMsg { StartWebServerMsg start_web_server = 11; RenamedSessionMsg renamed_session = 12; ConfigFileUpdatedMsg config_file_updated = 13; + PaneRenderUpdateMsg pane_render_update = 14; + SubscribedPaneClosedMsg subscribed_pane_closed = 15; } } @@ -74,3 +76,15 @@ message RenamedSessionMsg { message ConfigFileUpdatedMsg { // Empty message } + +message PaneRenderUpdateMsg { + PaneId pane_id = 1; + repeated string viewport = 2; + repeated string scrollback = 3; + bool has_scrollback = 4; + bool is_initial = 5; +} + +message SubscribedPaneClosedMsg { + PaneId pane_id = 1; +} diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs index aec1d0f0..221a7b96 100644 --- a/zellij-utils/src/errors.rs +++ b/zellij-utils/src/errors.rs @@ -415,6 +415,8 @@ pub enum ScreenContext { ClearMouseHelpText, SetPluginRegexHighlights, ClearPluginHighlights, + SubscribeToPaneRenders, + NotifyPaneClosedToSubscribers, } /// Stack call representations corresponding to the different types of [`PtyInstruction`]s. diff --git a/zellij-utils/src/ipc.rs b/zellij-utils/src/ipc.rs index bff396a1..656bec4c 100644 --- a/zellij-utils/src/ipc.rs +++ b/zellij-utils/src/ipc.rs @@ -1,6 +1,6 @@ //! IPC stuff for starting to split things into a client and server model. use crate::{ - data::{ClientId, ConnectToSession, KeyWithModifier, Style}, + data::{ClientId, ConnectToSession, KeyWithModifier, PaneId, Style}, errors::{prelude::*, ErrorContext}, input::{actions::Action, cli_assets::CliAssets}, pane_size::{Size, SizeInPixels}, @@ -148,6 +148,10 @@ pub enum ClientToServerMsg { FailedToStartWebServer { error: String, }, + SubscribeToPaneRenders { + pane_ids: Vec<PaneId>, + scrollback: Option<usize>, + }, } // Types of messages sent from the server to the client @@ -183,6 +187,15 @@ pub enum ServerToClientMsg { name: String, }, ConfigFileUpdated, + PaneRenderUpdate { + pane_id: PaneId, + viewport: Vec<String>, + scrollback: Option<Vec<String>>, + is_initial: bool, + }, + SubscribedPaneClosed { + pane_id: PaneId, + }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] diff --git a/zellij-utils/src/ipc/protobuf_conversion.rs b/zellij-utils/src/ipc/protobuf_conversion.rs index 1d228340..d843dc9d 100644 --- a/zellij-utils/src/ipc/protobuf_conversion.rs +++ b/zellij-utils/src/ipc/protobuf_conversion.rs @@ -6,13 +6,14 @@ use crate::{ ConnStatusMsg, ConnectedMsg, DetachSessionMsg, ExitMsg, ExitReason as ProtoExitReason, FailedToStartWebServerMsg, FirstClientConnectedMsg, ForegroundColorMsg, InputMode as ProtoInputMode, KeyMsg, KillSessionMsg, LayoutMetadata as ProtoLayoutMetadata, - LogErrorMsg, LogMsg, PaneMetadata as ProtoPaneMetadata, QueryTerminalSizeMsg, - RenamedSessionMsg, RenderMsg, ServerToClientMsg as ProtoServerToClientMsg, - StartWebServerMsg, SwitchSessionMsg, TabMetadata as ProtoTabMetadata, + LogErrorMsg, LogMsg, PaneMetadata as ProtoPaneMetadata, PaneRenderUpdateMsg, + QueryTerminalSizeMsg, RenamedSessionMsg, RenderMsg, + ServerToClientMsg as ProtoServerToClientMsg, StartWebServerMsg, SubscribeToPaneRendersMsg, + SubscribedPaneClosedMsg, SwitchSessionMsg, TabMetadata as ProtoTabMetadata, TerminalPixelDimensionsMsg, TerminalResizeMsg, UnblockCliPipeInputMsg, UnblockInputThreadMsg, WebServerStartedMsg, }, - data::InputMode, + data::{InputMode, PaneId}, errors::prelude::*, ipc::{ ClientToServerMsg, ColorRegister, ExitReason, PaneReference, PixelDimensions, @@ -114,6 +115,13 @@ impl From<ClientToServerMsg> for ProtoClientToServerMsg { error, }) }, + ClientToServerMsg::SubscribeToPaneRenders { + pane_ids, + scrollback, + } => client_to_server_msg::Message::SubscribeToPaneRenders(SubscribeToPaneRendersMsg { + pane_ids: pane_ids.into_iter().map(|id| id.into()).collect(), + scrollback: scrollback.map(|s| s as u32), + }), }; ProtoClientToServerMsg { @@ -228,6 +236,14 @@ impl TryFrom<ProtoClientToServerMsg> for ClientToServerMsg { error: failed.error, }) }, + Some(client_to_server_msg::Message::SubscribeToPaneRenders(msg)) => { + let pane_ids: Result<Vec<PaneId>> = + msg.pane_ids.into_iter().map(|p| p.try_into()).collect(); + Ok(ClientToServerMsg::SubscribeToPaneRenders { + pane_ids: pane_ids?, + scrollback: msg.scrollback.map(|s| s as usize), + }) + }, None => Err(anyhow!("Empty ClientToServerMsg message")), } } @@ -290,6 +306,23 @@ impl From<ServerToClientMsg> for ProtoServerToClientMsg { ServerToClientMsg::ConfigFileUpdated => { server_to_client_msg::Message::ConfigFileUpdated(ConfigFileUpdatedMsg {}) }, + ServerToClientMsg::PaneRenderUpdate { + pane_id, + viewport, + scrollback, + is_initial, + } => server_to_client_msg::Message::PaneRenderUpdate(PaneRenderUpdateMsg { + pane_id: Some(pane_id.into()), + viewport, + scrollback: scrollback.clone().unwrap_or_default(), + has_scrollback: scrollback.is_some(), + is_initial, + }), + ServerToClientMsg::SubscribedPaneClosed { pane_id } => { + server_to_client_msg::Message::SubscribedPaneClosed(SubscribedPaneClosedMsg { + pane_id: Some(pane_id.into()), + }) + }, }; ProtoServerToClientMsg { @@ -372,6 +405,30 @@ impl TryFrom<ProtoServerToClientMsg> for ServerToClientMsg { Some(server_to_client_msg::Message::ConfigFileUpdated(_)) => { Ok(ServerToClientMsg::ConfigFileUpdated) }, + Some(server_to_client_msg::Message::PaneRenderUpdate(msg)) => { + let pane_id: PaneId = msg + .pane_id + .ok_or_else(|| anyhow!("Missing pane_id"))? + .try_into()?; + let scrollback = if msg.has_scrollback { + Some(msg.scrollback) + } else { + None + }; + Ok(ServerToClientMsg::PaneRenderUpdate { + pane_id, + viewport: msg.viewport, + scrollback, + is_initial: msg.is_initial, + }) + }, + Some(server_to_client_msg::Message::SubscribedPaneClosed(msg)) => { + let pane_id: PaneId = msg + .pane_id + .ok_or_else(|| anyhow!("Missing pane_id"))? + .try_into()?; + Ok(ServerToClientMsg::SubscribedPaneClosed { pane_id }) + }, None => Err(anyhow!("Empty ServerToClientMsg message")), } } diff --git a/zellij-utils/src/ipc/tests/roundtrip_tests.rs b/zellij-utils/src/ipc/tests/roundtrip_tests.rs index fb8e28f8..aee81757 100644 --- a/zellij-utils/src/ipc/tests/roundtrip_tests.rs +++ b/zellij-utils/src/ipc/tests/roundtrip_tests.rs @@ -3040,6 +3040,18 @@ fn test_client_messages() { test_client_roundtrip!(ClientToServerMsg::FailedToStartWebServer { error: "Port already in use".to_string(), }); + test_client_roundtrip!(ClientToServerMsg::SubscribeToPaneRenders { + pane_ids: vec![PaneId::Terminal(1), PaneId::Plugin(2)], + scrollback: Some(100), + }); + test_client_roundtrip!(ClientToServerMsg::SubscribeToPaneRenders { + pane_ids: vec![PaneId::Terminal(0)], + scrollback: None, + }); + test_client_roundtrip!(ClientToServerMsg::SubscribeToPaneRenders { + pane_ids: vec![PaneId::Terminal(1)], + scrollback: Some(0), + }); } fn test_server_messages() { @@ -3151,6 +3163,30 @@ fn test_server_messages() { cwd: Some(PathBuf::from("/path/to/cwd")), } }); + test_server_roundtrip!(ServerToClientMsg::PaneRenderUpdate { + pane_id: PaneId::Terminal(1), + viewport: vec!["hello".to_string(), "world".to_string()], + scrollback: Some(vec!["line1".to_string()]), + is_initial: true, + }); + test_server_roundtrip!(ServerToClientMsg::PaneRenderUpdate { + pane_id: PaneId::Plugin(2), + viewport: vec!["viewport line".to_string()], + scrollback: None, + is_initial: false, + }); + test_server_roundtrip!(ServerToClientMsg::PaneRenderUpdate { + pane_id: PaneId::Terminal(0), + viewport: vec![], + scrollback: Some(vec![]), + is_initial: true, + }); + test_server_roundtrip!(ServerToClientMsg::SubscribedPaneClosed { + pane_id: PaneId::Terminal(1), + }); + test_server_roundtrip!(ServerToClientMsg::SubscribedPaneClosed { + pane_id: PaneId::Plugin(3), + }); } #[test] |
