diff options
| author | user <user@clank> | 2026-08-12 19:34:15 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 19:34:15 +0200 |
| commit | d1c14ff0f5c5b55c46d91ad12f4a291041480a99 (patch) | |
| tree | 9dca9628d228b211a6919c9dd65cd7a9b4fcf6b0 /sl0ppij/zellij-server/src/screen.rs | |
| parent | chore: initialize sl0ppi monorepo (diff) | |
| download | sl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.tar.gz sl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.tar.xz sl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.zip | |
vendor(sl0ppij): import zellij v0.45.0 tree
Squashed import of upstream zellij at 0e6e4404 ("Support the Kitty Image
Protocol (#5428)"), the merge-base our fork was developed against. Upstream's
3311 commits are deliberately NOT replayed here; only our own fork commits are,
so the monorepo stays small and `git log sl0ppij/` shows sl0p work, not zellij's
entire history.
Upstream provenance is tracked in FORKS.md and can always be re-fetched from
https://github.com/zellij-org/zellij.
Diffstat (limited to 'sl0ppij/zellij-server/src/screen.rs')
| -rw-r--r-- | sl0ppij/zellij-server/src/screen.rs | 12110 |
1 files changed, 12110 insertions, 0 deletions
diff --git a/sl0ppij/zellij-server/src/screen.rs b/sl0ppij/zellij-server/src/screen.rs new file mode 100644 index 0000000..bec746e --- /dev/null +++ b/sl0ppij/zellij-server/src/screen.rs @@ -0,0 +1,12110 @@ +//! Things related to [`Screen`]s. +//! +//! # Tab Identification +//! +//! Tabs have two distinct identifiers: +//! +//! - **ID** (`tab.id`): Stable, unique identifier that never changes after creation. +//! Used as BTreeMap key and for internal tracking. Monotonically increasing. +//! +//! - **Position** (`tab.position`): Current display order (0-based index in tab bar). +//! Changes when tabs are moved or closed. Used for user-facing operations. +//! +//! # Terminology Convention +//! +//! - **"id"**: Always means stable identifier +//! - **"position"**: Always means 0-based display order +//! - **"index"**: Synonym for "position" (used in public/plugin APIs) +//! +//! Examples: +//! - `close_tab_by_id(5)` - Closes tab with stable ID 5 +//! - `CloseTabWithIndex(2)` - Closes tab at position 2 (3rd tab visually) +//! - `get_tab_by_position(0)` - Gets first tab in display order +//! - `PluginInstruction::NewTab(tab_id, ...)` - Uses ID for async communication +//! +//! # Key Data Structures +//! +//! - `tabs: BTreeMap<usize, Tab>`: Keyed by tab.id (stable identifier) +//! - `active_tab_ids: BTreeMap<ClientId, usize>`: Maps clients to active tab ID +//! - `tab_history: BTreeMap<ClientId, Vec<usize>>`: History of tab IDs per client + +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::net::{IpAddr, Ipv4Addr}; +use std::path::PathBuf; +use std::rc::Rc; +use std::str; +use std::time::{Duration, Instant}; + +use crate::route::NotificationEnd; + +use log::{debug, warn}; +use zellij_utils::data::{ + CommandOrPlugin, Direction, EventType, FloatingPaneCoordinates, GetFocusedPaneInfoResponse, + HostTerminalThemeMode, KeyWithModifier, LayoutInfo, LayoutWithError, ListPanesResponse, + ListTabsResponse, NewPanePlacement, PaneContents, PaneInfo, PaneListEntry, PaneManifest, + PaneRenderReport, PaneScrollbackResponse, PluginPermission, RegexHighlight, Resize, + ResizeStrategy, SessionInfo, Styling, TabInfo, ThemeHue, WebSharing, +}; +use zellij_utils::errors::prelude::*; +use zellij_utils::input::actions::Action; +use zellij_utils::input::command::RunCommand; +use zellij_utils::input::config::Config; +use zellij_utils::input::keybinds::{shortcut_for_action, Keybinds}; +use zellij_utils::input::mouse::{MouseEvent, MouseEventType}; +use zellij_utils::input::options::{ + Clipboard, MobileLayoutConfiguration, NestedSessionHandling, PaneFrameStyle, +}; +use zellij_utils::ipc::{ExitReason, ServerToClientMsg}; +use zellij_utils::pane_size::{PaneGeom, Size, SizeInPixels}; +use zellij_utils::shared::{clean_string_from_control_and_linebreak, detect_theme_hue}; +use zellij_utils::{ + consts::{session_info_folder_for_session, ZELLIJ_SOCK_DIR}, + envs::set_session_name, + input::command::TerminalAction, + input::layout::{ + FloatingPaneLayout, Layout, PercentOrFixed, Run, RunPluginOrAlias, SwapFloatingLayout, + SwapTiledLayout, TabLayoutInfo, TiledPaneLayout, + }, + position::Position, +}; + +use crate::background_jobs::BackgroundJob; +use crate::os_input_output::ResizeCache; +use crate::pane_groups::PaneGroups; +use crate::panes::alacritty_functions::xparse_color; +use crate::panes::nested_session_modal::GuestModalShortcuts; +use crate::panes::terminal_character::AnsiCode; +use crate::panes::terminal_pane::{BRACKETED_PASTE_BEGIN, BRACKETED_PASTE_END}; +use crate::session_layout_metadata::{PaneLayoutMetadata, SessionLayoutMetadata}; + +use crate::{ + nested_guest::NestedGuestTracker, + output::{HostKittyState, Output}, + panes::kitty_graphics::{KittyHostSupport, KittyImageStore}, + panes::sixel::SixelImageStore, + panes::PaneId, + plugins::{DumpSessionLayoutResponse, PluginId, PluginInstruction, PluginRenderAsset}, + pty::{get_default_shell, ClientTabIndexOrPaneId, PtyInstruction, VteBytes}, + pty_writer::PtyWriteInstruction, + tab::{GuestChoiceIndicator, SuppressedPanes, Tab}, + thread_bus::Bus, + ui::loading_indication::LoadingIndication, + ClientId, ServerInstruction, +}; +use zellij_utils::{ + data::{Event, InputMode, ModeInfo, Palette, PaletteColor, PluginCapabilities, Style}, + errors::{ContextType, ScreenContext}, + input::get_mode_info, + ipc::{ClientAttributes, PixelDimensions}, + nested_session::{self, NestedSessionCapability, NestedSessionMessage}, +}; + +use crate::mobile_mode::{MobileRenderGate, MobileState, ShadowFocusOutcome, FIT_RESIZE_MAX_ITERS}; + +/// Parses a namespaced OSC 99 response and extracts the original pane ID +/// and un-namespaced response bytes. +/// +/// Input bytes (the termwiz OperatingSystemCommand payload after stripping "99;"): +/// e.g. b"i=p42.mynotif" or b"i=p42.mynotif:p=close;some_data" +/// +/// Returns Some((terminal_id, full_osc_bytes)) where full_osc_bytes is +/// the complete reconstructed OSC 99 sequence with original identifier, +/// ready to write to the pane's PTY. +/// Denormalizes a namespaced OSC 99 response. +/// +/// Parses the namespaced `i=p<N>[r][q].<original_id>` format and returns: +/// - `pane_id`: the terminal pane that originated the notification +/// - `app_wants_report`: `r` flag — app originally requested `a=report` +/// - `is_query`: `q` flag — this was a capability query (`p=?`) +/// - `restored_response_bytes`: the response with the original `i=` value restored +pub(crate) fn denormalize_notification_response( + payload: &[u8], +) -> Option<(u32, bool, bool, Vec<u8>)> { + let payload_str = str::from_utf8(payload).ok()?; + + // Split into metadata and response payload on first ';' + let (metadata, response_payload) = match payload_str.find(';') { + Some(idx) => ( + payload_str.get(..idx).unwrap_or_default(), + payload_str.get(idx..).unwrap_or_default(), + ), + None => (payload_str, ""), + }; + + // Find the i= key in colon-separated metadata + let mut terminal_id = None; + let mut app_wants_report = false; + let mut is_query = false; + let mut restored_parts = Vec::new(); + + for kv in metadata.split(':') { + if let Some(namespaced_value) = kv.strip_prefix("i=p") { + // Parse "p<N>[r][q].<original_id>" + if let Some(dot_pos) = namespaced_value.find('.') { + let flags_part = namespaced_value.get(..dot_pos).unwrap_or_default(); + let original_id = namespaced_value.get(dot_pos + 1..).unwrap_or_default(); + let pane_id_str = flags_part.trim_end_matches(|c| c == 'r' || c == 'q'); + let flag_chars = flags_part.get(pane_id_str.len()..).unwrap_or_default(); + if let Ok(pid) = pane_id_str.parse::<u32>() { + terminal_id = Some(pid); + app_wants_report = flag_chars.contains('r'); + is_query = flag_chars.contains('q'); + // Empty original_id means the app never sent an i= key; + // don't inject one into the response + if !original_id.is_empty() { + restored_parts.push(format!("i={}", original_id)); + } + continue; + } + } + } + restored_parts.push(kv.to_string()); + } + + let terminal_id = terminal_id?; + let restored_metadata = restored_parts.join(":"); + let full_response = format!("\x1b]99;{}{}\x1b\\", restored_metadata, response_payload); + Some(( + terminal_id, + app_wants_report, + is_query, + full_response.into_bytes(), + )) +} + +/// Get the active tab and call a closure on it +/// +/// If no active tab can be found, an error is logged instead. +/// +/// # Parameters +/// +/// - screen: An instance of `Screen` to operate on +/// - client_id: The client_id, usually taken from the `ScreenInstruction` that's being processed +/// - closure: A closure satisfying `|tab: &mut Tab| -> ()` OR `|tab: &mut Tab| -> Result<T>` (see +/// '?' below) +/// - ?: A literal "?", to append a `?` to the closure when it returns a `Result` type. This +/// argument is optional and not needed when the closure returns `()` +macro_rules! active_tab { + ($screen:ident, $client_id:ident, $closure:expr) => { + match $screen.get_active_tab_mut($client_id) { + Ok(active_tab) => { + // This could be made more ergonomic by declaring the type of 'active_tab' in the + // closure, known as "Type Ascription". Then we could hint the type here and forego the + // "&mut Tab" in all the closures below... + // See: https://github.com/rust-lang/rust/issues/23416 + $closure(active_tab); + }, + Err(err) => Err::<(), _>(err).non_fatal(), + }; + }; + // Same as above, but with an added `?` for when the close returns a `Result` type. + ($screen:ident, $client_id:ident, $closure:expr, ?) => { + match $screen.get_active_tab_mut($client_id) { + Ok(active_tab) => { + $closure(active_tab)?; + }, + Err(err) => Err::<(), _>(err).non_fatal(), + }; + }; +} + +macro_rules! active_tab_and_connected_client_id { + ($screen:ident, $client_id:ident, $closure:expr) => { + match $screen.get_active_tab_mut($client_id) { + Ok(active_tab) => { + $closure(active_tab, $client_id); + }, + Err(_) => { + if let Some(client_id) = $screen.get_first_client_id() { + match $screen.get_active_tab_mut(client_id) { + Ok(active_tab) => { + $closure(active_tab, client_id); + }, + Err(err) => Err::<(), _>(err).non_fatal(), + } + } else { + log::error!("No client ids in screen found"); + }; + }, + } + }; + // Same as above, but with an added `?` for when the closure returns a `Result` type. + ($screen:ident, $client_id:ident, $closure:expr, ?) => { + match $screen.get_active_tab_mut($client_id) { + Ok(active_tab) => { + $closure(active_tab, $client_id).non_fatal(); + }, + Err(_) => { + if let Some(client_id) = $screen.get_first_client_id() { + match $screen.get_active_tab_mut(client_id) { + Ok(active_tab) => { + $closure(active_tab, client_id)?; + }, + Err(err) => Err::<(), _>(err).non_fatal(), + } + } else { + log::error!("No client ids in screen found"); + }; + }, + } + }; +} + +macro_rules! active_tab_and_connected_client_id_with_first_tab_fallback { + ($screen:ident, $client_id:ident, $closure:expr) => { + match $screen.get_active_tab_mut($client_id) { + Ok(active_tab) => { + $closure(active_tab, Some($client_id)); + }, + Err(_) => { + if let Some(client_id) = $screen.get_first_client_id() { + match $screen.get_active_tab_mut(client_id) { + Ok(active_tab) => { + $closure(active_tab, Some(client_id)); + }, + Err(err) => Err::<(), _>(err).non_fatal(), + } + } else { + match $screen.get_indexed_tab_mut(0) { + Some(first_tab) => { + $closure(first_tab, None); + }, + None => { + log::error!("Not tabs found!"); + }, + } + }; + }, + } + }; + // Same as above, but with an added `?` for when the closure returns a `Result` type. + ($screen:ident, $client_id:ident, $closure:expr, ?) => { + match $screen.get_active_tab_mut($client_id) { + Ok(active_tab) => { + $closure(active_tab, Some($client_id)).non_fatal(); + }, + Err(_) => { + if let Some(client_id) = $screen.get_first_client_id() { + match $screen.get_active_tab_mut(client_id) { + Ok(active_tab) => { + $closure(active_tab, Some(client_id))?; + }, + Err(err) => Err::<(), _>(err).non_fatal(), + } + } else { + match $screen.get_indexed_tab_mut(0) { + Some(first_tab) => { + $closure(first_tab, None)?; + }, + None => { + log::error!("Not tabs found!"); + }, + } + }; + }, + } + }; +} + +type InitialTitle = String; +type HoldForCommand = Option<RunCommand>; + +/// Result of overriding a single tab's layout +#[derive(Debug, Clone)] +pub struct TabOverrideResult { + pub tab_index: usize, + pub tab_name: Option<String>, + pub tiled_layout: TiledPaneLayout, + pub floating_layouts: Vec<FloatingPaneLayout>, + pub swap_tiled_layouts: Option<Vec<SwapTiledLayout>>, + pub swap_floating_layouts: Option<Vec<SwapFloatingLayout>>, + pub new_terminal_pids: Vec<(u32, HoldForCommand)>, + pub new_floating_pane_pids: Vec<(u32, HoldForCommand)>, + pub plugin_ids: HashMap<RunPluginOrAlias, Vec<u32>>, +} + +/// Instructions that can be sent to the [`Screen`]. +#[derive(Debug, Clone)] +pub enum ScreenInstruction { + PtyBytes(u32, VteBytes), + PluginBytes(Vec<PluginRenderAsset>), + Render, + RenderToClients, + NewPane( + PaneId, + Option<InitialTitle>, + HoldForCommand, + Option<Run>, // invoked with + NewPanePlacement, + bool, // start suppressed + ClientTabIndexOrPaneId, + Option<NotificationEnd>, // completion signal + bool, // set_blocking + ), + OpenInPlaceEditor(PaneId, ClientTabIndexOrPaneId), + TogglePaneEmbedOrFloating(ClientId, Option<NotificationEnd>), + ToggleFloatingPanes(ClientId, Option<TerminalAction>, Option<NotificationEnd>), + ShowFloatingPanes { + client_id: ClientId, + tab_id: Option<usize>, + completion: Option<NotificationEnd>, + }, + HideFloatingPanes { + client_id: ClientId, + tab_id: Option<usize>, + completion: Option<NotificationEnd>, + }, + AreFloatingPanesVisible { + client_id: ClientId, + tab_id: Option<usize>, + completion: Option<NotificationEnd>, + }, + WriteCharacter( + Option<KeyWithModifier>, + Vec<u8>, + bool, + ClientId, + Option<NotificationEnd>, + ), // bool -> + // is_kitty_keyboard_protocol + Resize(ClientId, ResizeStrategy, Option<NotificationEnd>), + SwitchFocus(ClientId, Option<NotificationEnd>), + FocusNextPane(ClientId, Option<NotificationEnd>), + FocusPreviousPane(ClientId, Option<NotificationEnd>), + FocusLastPane(ClientId, Option<NotificationEnd>), + MoveFocusLeft(ClientId, Option<NotificationEnd>), + MoveFocusLeftOrPreviousTab(ClientId, Option<NotificationEnd>), + MoveFocusDown(ClientId, Option<NotificationEnd>), + MoveFocusUp(ClientId, Option<NotificationEnd>), + MoveFocusRight(ClientId, Option<NotificationEnd>), + MoveFocusRightOrNextTab(ClientId, Option<NotificationEnd>), + MovePane(ClientId, Option<NotificationEnd>), + MovePaneBackwards(ClientId, Option<NotificationEnd>), + MovePaneUp(ClientId, Option<NotificationEnd>), + MovePaneDown(ClientId, Option<NotificationEnd>), + MovePaneRight(ClientId, Option<NotificationEnd>), + MovePaneLeft(ClientId, Option<NotificationEnd>), + Exit, + ClearScreen(ClientId, Option<NotificationEnd>), + DumpScreen( + Option<String>, + ClientId, + bool, + Option<PaneId>, + Option<NotificationEnd>, + Option<ClientId>, // cli_client_id - used to send output to the CLI client's STDOUT + bool, // ansi - preserve ANSI styling in the dump output + ), + DumpLayout(Option<PathBuf>, ClientId, Option<NotificationEnd>), // PathBuf is the default configured + // shell + SaveSession(ClientId, Option<NotificationEnd>), + DumpLayoutToPlugin { + plugin_id: PluginId, + tab_index: Option<usize>, + response_channel: crossbeam::channel::Sender<DumpSessionLayoutResponse>, + }, + GetFocusedPaneInfo { + client_id: ClientId, + response_channel: crossbeam::channel::Sender<GetFocusedPaneInfoResponse>, + }, + GetPaneInfo { + pane_id: PaneId, + response_channel: crossbeam::channel::Sender<Option<PaneInfo>>, + }, + GetTabInfo { + tab_id: usize, + response_channel: crossbeam::channel::Sender<Option<TabInfo>>, + }, + EditScrollback(ClientId, bool, Option<NotificationEnd>), + GetPaneScrollback { + pane_id: PaneId, + client_id: ClientId, + get_full_scrollback: bool, + response_channel: crossbeam::channel::Sender<PaneScrollbackResponse>, + }, + ScrollUp(ClientId, Option<NotificationEnd>), + ScrollUpAt(Position, ClientId, Option<NotificationEnd>), + ScrollDown(ClientId, Option<NotificationEnd>), + ScrollDownAt(Position, ClientId, Option<NotificationEnd>), + ScrollToBottom(ClientId, Option<NotificationEnd>), + ScrollToTop(ClientId, Option<NotificationEnd>), + PageScrollUp(ClientId, Option<NotificationEnd>), + PageScrollDown(ClientId, Option<NotificationEnd>), + HalfPageScrollUp(ClientId, Option<NotificationEnd>), + HalfPageScrollDown(ClientId, Option<NotificationEnd>), + ClearScroll(ClientId), + CloseFocusedPane(ClientId, Option<NotificationEnd>), + ToggleActiveTerminalFullscreen(ClientId, Option<NotificationEnd>), + ToggleActiveTerminalNoUiFullscreen(ClientId, Option<NotificationEnd>), + TogglePaneFrames(Option<NotificationEnd>), + SetPaneFrameStyle(PaneFrameStyle, Option<NotificationEnd>), + SetSelectable(PaneId, bool), + ShowPluginCursor(u32, ClientId, Option<(usize, usize)>), + ClosePane( + PaneId, + Option<ClientId>, + Option<NotificationEnd>, + Option<i32>, + ), // i32 -> optional exit + // status + HoldPane(PaneId, Option<i32>, RunCommand), + UpdatePaneName(Vec<u8>, ClientId, Option<NotificationEnd>), + UndoRenamePane(ClientId, Option<NotificationEnd>), + NewTab( + Option<PathBuf>, + Option<TerminalAction>, + Option<TiledPaneLayout>, + Vec<FloatingPaneLayout>, + Option<String>, + // `None` falls back to `Screen::default_layout`'s swap layouts. + ( + Option<Vec<SwapTiledLayout>>, + Option<Vec<SwapFloatingLayout>>, + ), + Option<Vec<CommandOrPlugin>>, // initial_panes + bool, // block_on_first_terminal + bool, // should_change_focus_to_new_tab + (ClientId, bool), // bool -> is_web_client + Option<NotificationEnd>, // completion signal + ), + /// Apply layout to tab with given stable ID. + /// + /// The sixth parameter (usize) is a stable identifier (not position) from the + /// NewTab → ApplyLayout async flow that passes IDs between threads. + ApplyLayout( + TiledPaneLayout, + Vec<FloatingPaneLayout>, + Vec<(u32, HoldForCommand)>, // new pane pids + Vec<(u32, HoldForCommand)>, // new floating pane pids + HashMap<RunPluginOrAlias, Vec<u32>>, + usize, // tab_id - stable identifier from NewTab instruction + bool, // should change focus to new tab + (ClientId, bool), // bool -> is_web_client + Option<NotificationEnd>, // regular completion signal + Option<(u32, NotificationEnd)>, // blocking_terminal (terminal_id, completion_tx) + ), + SwitchTabNext(ClientId, Option<NotificationEnd>), + SwitchTabPrev(ClientId, Option<NotificationEnd>), + ToggleActiveSyncTab(ClientId, Option<NotificationEnd>), + CloseTab(ClientId, Option<NotificationEnd>), + GoToTab(u32, Option<ClientId>, Option<NotificationEnd>), // this Option is a hacky workaround, please do not copy this behaviour + GoToTabName( + String, + Option<TerminalAction>, // default_shell + bool, + Option<ClientId>, + Option<NotificationEnd>, + ), + ToggleTab(ClientId, Option<NotificationEnd>), + UpdateTabName(Vec<u8>, ClientId, Option<NotificationEnd>), + UndoRenameTab(ClientId, Option<NotificationEnd>), + MoveTabLeft(ClientId, Option<NotificationEnd>), + MoveTabRight(ClientId, Option<NotificationEnd>), + GoToTabWithId(usize, Option<ClientId>, Option<NotificationEnd>), + CloseTabWithId(usize, Option<NotificationEnd>), + RenameTabWithId(usize, Vec<u8>, Option<NotificationEnd>), + BreakPanesToTabWithId { + pane_ids: Vec<PaneId>, + tab_id: usize, + should_change_focus_to_target_tab: bool, + client_id: ClientId, + completion_tx: Option<NotificationEnd>, + }, + TerminalResize(Size), + RecomputeTabSize(ClientId, Size), + TerminalPixelDimensions(PixelDimensions), + TerminalBackgroundColor(String), + TerminalForegroundColor(String), + TerminalColorRegisters(Vec<(usize, String)>), + SetKittyGraphicsSupport { + client_id: ClientId, + supported: bool, + }, + /// A pane's Grid intercepted an app-in-pane whitelisted query; Screen + /// assigns a token, queues the forward, and dispatches to the client. + /// `query` carries the classified form so Screen can match on it + /// directly (for cache-fallback synthesis) without re-parsing bytes. + ForwardHostQuery { + pane_id: PaneId, + query: crate::host_query::HostQuery, + }, + NestedSessionMessageFromPane { + pane_id: PaneId, + message: NestedSessionMessage, + }, + NestedGuestPingTick { + pane_id: PaneId, + }, + NestedSessionMessageFromHost { + client_id: ClientId, + message: NestedSessionMessage, + }, + GuestModalChoice { + client_id: ClientId, + pane_id: PaneId, + outcome: GuestModalOutcome, + }, + /// The client observed the host's reply to a previously forwarded + /// query (closed by the Primary-DA barrier or the 500 ms timeout). + /// The reply bytes are written verbatim to the originating pane. + ForwardedReplyFromHost { + token: u32, + reply_bytes: Vec<u8>, + }, + /// Internal: a forwarded reply (or its cache-fallback synthesis, + /// or a locally-answered query like `ColorPaletteMode`) is ready + /// to be delivered to the originating pane. Routed through the + /// owning Tab so that the bytes are written to PTY *and* any + /// PTY input that arrived while the pane was forward-paused is + /// drained and re-fed through vte in stream order. This preserves + /// query/reply ordering even when sync replies (DA1, DSR, DECQRM) + /// would otherwise race ahead of an async host round-trip. + ResumePaneAfterForward { + pane_id: PaneId, + reply_bytes: Vec<u8>, + }, + /// The host terminal reported a color-palette theme mode (DSR 997 reply + /// to `CSI ? 996 n`, or unsolicited notification while `CSI ? 2031 h` + /// is enabled). Triggers auto-theme switch (when configured), the + /// `HostTerminalThemeChanged` plugin event, and per-pane DSR forwarding + /// for panes that opted in via `CSI ? 2031 h`. + HostTerminalThemeChanged(HostTerminalThemeMode), + /// Manual theme actions issued via the CLI (e.g. `zellij action set-dark-theme`) + /// or a keybinding. They share the same convergence point as + /// `HostTerminalThemeChanged`, but additionally surface a CLI-friendly error + /// via `NotificationEnd` if `theme_dark` and `theme_light` are not both set + /// (the auto-switch gate). "Last one wins": these compete naively with + /// terminal-driven notifications via the dedupe in + /// `update_host_terminal_theme_mode`. + SetDarkTheme(Option<NotificationEnd>), + SetLightTheme(Option<NotificationEnd>), + ToggleTheme(Option<NotificationEnd>), + ChangeMode( + InputMode, + Option<InputMode>, + ClientId, + Option<NotificationEnd>, + ), + ChangeModeForAllClients(InputMode, Option<InputMode>, Option<NotificationEnd>), + MouseEvent(MouseEvent, ClientId, Option<NotificationEnd>), + Copy(ClientId, Option<NotificationEnd>), + AddClient( + ClientId, + bool, // is_web_client + Size, // client viewport size + Option<usize>, // tab position to focus + Option<(u32, bool)>, // (pane_id, is_plugin) => pane_id to focus + ), + RemoveClient(ClientId), + SuppressRenderUntilMobile(ClientId), + MobileSizeSettled(ClientId), + ForceMobileUngate(ClientId), + UpdateSearch(Vec<u8>, ClientId, Option<NotificationEnd>), + SearchDown(ClientId, Option<NotificationEnd>), + SearchUp(ClientId, Option<NotificationEnd>), + SearchToggleCaseSensitivity(ClientId, Option<NotificationEnd>), + SearchToggleWholeWord(ClientId, Option<NotificationEnd>), + SearchToggleWrap(ClientId, Option<NotificationEnd>), + AddRedPaneFrameColorOverride(Vec<PaneId>, Option<String>), // Option<String> => optional error text + ClearPaneFrameColorOverride(Vec<PaneId>), + SetTabBellFlash(usize, bool), // tab_id, is_flashing + PreviousSwapLayout(ClientId, Option<NotificationEnd>), + NextSwapLayout(ClientId, Option<NotificationEnd>), + OverrideLayout( + Option<PathBuf>, // cwd (applies to all tabs) + Option<TerminalAction>, // default_shell (applies to all tabs) + Vec<TabLayoutInfo>, // layouts for each tab to override + bool, // retain_existing_terminal_panes + bool, // retain_existing_plugin_panes + bool, // apply_only_to_focused_tab + ClientId, + Option<NotificationEnd>, + ), + OverrideLayoutComplete( + Vec<TabOverrideResult>, // results for each tab + bool, // retain_existing_terminal_panes + bool, // retain_existing_plugin_panes + ClientId, + Option<NotificationEnd>, + ), + QueryTabNames(ClientId, Option<NotificationEnd>), + NewTiledPluginPane( + RunPluginOrAlias, + Option<String>, + bool, + Option<PathBuf>, + ClientId, + Option<NotificationEnd>, + Option<usize>, // tab_id + bool, + ), // Option<String> is + // optional pane title, bool is skip cache, Option<PathBuf> is an optional cwd + NewFloatingPluginPane( + RunPluginOrAlias, + Option<String>, + bool, + Option<PathBuf>, + Option<FloatingPaneCoordinates>, + ClientId, + Option<NotificationEnd>, + Option<usize>, // tab_id + bool, + ), // Option<String> is an + // optional pane title, bool + // is skip cache, Option<PathBuf> is an optional cwd + NewInPlacePluginPane( + RunPluginOrAlias, + Option<String>, + PaneId, + bool, + bool, + ClientId, + Option<NotificationEnd>, + Option<usize>, // tab_id + bool, + ), // Option<String> is an + // optional pane title, first bool is skip cache, second bool is close_replaced_pane + StartOrReloadPluginPane(RunPluginOrAlias, Option<String>, Option<NotificationEnd>), + AddPlugin( + Option<bool>, // should_float + bool, // should be opened in place + bool, // close_replaced_pane + RunPluginOrAlias, + Option<String>, // pane title + Option<usize>, // tab index + u32, // plugin id + Option<PaneId>, + Option<PathBuf>, // cwd + bool, // start suppressed + Option<FloatingPaneCoordinates>, + Option<bool>, // should focus plugin + Option<ClientId>, + Option<NotificationEnd>, // completion signal + ), + UpdatePluginLoadingStage(u32, LoadingIndication), // u32 - plugin_id + StartPluginLoadingIndication(u32, LoadingIndication), // u32 - plugin_id + ProgressPluginLoadingOffset(u32), // u32 - plugin id + RequestStateUpdateForPlugins, + LaunchOrFocusPlugin( + RunPluginOrAlias, + bool, + bool, + bool, + bool, + Option<PaneId>, + bool, + ClientId, + Option<NotificationEnd>, + Option<usize>, // tab_id + ), // bools are: should_float, move_to_focused_tab, should_open_in_place, close_replaced_pane, Option<PaneId> is the pane id to replace, bool following it is skip_cache + LaunchPlugin( + RunPluginOrAlias, + bool, + bool, + bool, + Option<PaneId>, + bool, + Option<PathBuf>, + ClientId, + Option<NotificationEnd>, + Option<usize>, // tab_id + bool, + ), // bools are: should_float, should_open_in_place, close_replaced_pane, Option<PaneId> is the pane id to replace, Option<PathBuf> is an optional cwd, bool after is skip_cache + SuppressPane(PaneId, ClientId), + UnsuppressPane(PaneId, bool), // bool -> should float if hidden + UnsuppressOrExpandPane(PaneId, bool), // bool -> should float if hidden + FocusPaneWithId(PaneId, bool, bool, ClientId, Option<NotificationEnd>), // bools: + // should_float_if_hidden, + // should_be_in_place_if_hidden + RenamePane(PaneId, Vec<u8>, Option<NotificationEnd>), + RenameActivePane(Vec<u8>, ClientId, Option<NotificationEnd>), + RenameTab(usize, Vec<u8>, Option<NotificationEnd>), + RequestPluginPermissions( + u32, // u32 - plugin_id + PluginPermission, + ), + BreakPane(Option<TerminalAction>, ClientId, Option<NotificationEnd>), + BreakPaneRight(ClientId, Option<NotificationEnd>), + BreakPaneLeft(ClientId, Option<NotificationEnd>), + UpdateSessionInfos( + BTreeMap<String, SessionInfo>, // String is the session name + BTreeMap<String, Duration>, // resurrectable sessions - <name, created> + ), + ReplacePane( + PaneId, + HoldForCommand, + Option<InitialTitle>, + Option<Run>, + bool, // close replaced pane + ClientTabIndexOrPaneId, + Option<NotificationEnd>, // completion signal + ), + SerializeLayoutForResurrection, + RenameSession(String, ClientId, Option<NotificationEnd>), // String -> new name + ListClientsMetadata(Option<PathBuf>, ClientId, Option<NotificationEnd>), // Option<PathBuf> - default shell + ListPanes { + show_all: bool, + response_channel: crossbeam::channel::Sender<ListPanesResponse>, + }, + ListTabs { + client_id: ClientId, + response_channel: crossbeam::channel::Sender<ListTabsResponse>, + }, + GetCurrentTabInfo { + client_id: ClientId, + response_channel: crossbeam::channel::Sender<Option<TabInfo>>, + }, + Reconfigure { + client_id: ClientId, + keybinds: Keybinds, + default_mode: InputMode, + theme: Styling, + /// Resolved styling for `theme_dark`. When both this and + /// `host_theme_light` are `Some`, Screen auto-switches the active + /// palette in response to host CSI 2031 / DSR 997 notifications. + host_theme_dark: Option<Styling>, + /// Resolved styling for `theme_light`. See `host_theme_dark`. + host_theme_light: Option<Styling>, + simplified_ui: bool, + default_shell: Option<PathBuf>, + pane_frame_style: PaneFrameStyle, + copy_command: Option<String>, + copy_to_clipboard: Option<Clipboard>, + copy_on_select: bool, + auto_layout: bool, + rounded_corners: bool, + hide_session_name: bool, + stacked_resize: bool, + stacked_pane_list: bool, + default_editor: Option<PathBuf>, + advanced_mouse_actions: bool, + mouse_scroll_resize: bool, + mouse_hover_effects: bool, + visual_bell: bool, + focus_follows_mouse: bool, + mouse_click_through: bool, + nested_session_handling: NestedSessionHandling, + }, + RerunCommandPane(u32, Option<NotificationEnd>), // u32 - terminal pane id + ResizePaneWithId(ResizeStrategy, PaneId), + EditScrollbackForPaneWithId(PaneId, Option<NotificationEnd>), + WriteToPaneId(Vec<u8>, PaneId, Option<NotificationEnd>), + Paste(Vec<u8>, Option<PaneId>, ClientId, Option<NotificationEnd>), + SetPaneColor( + PaneId, + Option<String>, + Option<String>, + Option<NotificationEnd>, + ), + WriteKeyToPaneId( + Option<KeyWithModifier>, + Vec<u8>, + bool, // is_kitty_keyboard_protocol + PaneId, + Option<NotificationEnd>, + ), + CopyTextToClipboard(String, u32), // String - text to copy, u32 - plugin_id + MovePaneWithPaneId(PaneId), + MovePaneWithPaneIdInDirection(PaneId, Direction), + ClearScreenForPaneId(PaneId), + ScrollUpInPaneId(PaneId), + ScrollDownInPaneId(PaneId), + ScrollToTopInPaneId(PaneId), + ScrollToBottomInPaneId(PaneId), + PageScrollUpInPaneId(PaneId), + PageScrollDownInPaneId(PaneId), + TogglePaneIdFullscreen(PaneId), + SetTabFit { + client_id: ClientId, + tab_id: usize, + fit: Option<(PaneId, Size)>, + }, + TogglePaneEmbedOrEjectForPaneId(PaneId), + CloseTabWithIndex(usize), + BreakPanesToNewTab { + pane_ids: Vec<PaneId>, + default_shell: Option<TerminalAction>, + should_change_focus_to_new_tab: bool, + new_tab_name: Option<String>, + client_id: ClientId, + completion_tx: Option<NotificationEnd>, + }, + BreakPanesToTabWithIndex { + pane_ids: Vec<PaneId>, + tab_index: usize, + should_change_focus_to_new_tab: bool, + client_id: ClientId, + completion_tx: Option<NotificationEnd>, + }, + ListClientsToPlugin(PluginId, ClientId), + TogglePanePinned(ClientId, Option<NotificationEnd>), + SetFloatingPanePinned(PaneId, bool), + StackPanes(Vec<PaneId>, ClientId, Option<NotificationEnd>), + ChangeFloatingPanesCoordinates( + Vec<(PaneId, FloatingPaneCoordinates)>, + Option<NotificationEnd>, + ), + TogglePaneBorderless(PaneId, Option<NotificationEnd>), + SetPaneBorderless(PaneId, bool, Option<NotificationEnd>), + AddHighlightPaneFrameColorOverride(Vec<PaneId>, Option<String>), // Option<String> => optional + // message + GroupAndUngroupPanes(Vec<PaneId>, Vec<PaneId>, bool, ClientId), // panes_to_group, panes_to_ungroup, bool -> for all clients + HighlightAndUnhighlightPanes(Vec<PaneId>, Vec<PaneId>, ClientId), // panes_to_highlight, panes_to_unhighlight + FloatMultiplePanes(Vec<PaneId>, ClientId), + EmbedMultiplePanes(Vec<PaneId>, ClientId), + TogglePaneInGroup(ClientId, Option<NotificationEnd>), + ToggleGroupMarking(ClientId, Option<NotificationEnd>), + SessionSharingStatusChange(bool), + SetMouseSelectionSupport(PaneId, bool), + InterceptKeyPresses(PluginId, ClientId), + ClearKeyPressesIntercepts(ClientId), + ReplacePaneWithExistingPane(PaneId, PaneId, bool, Option<NotificationEnd>), // bool -> suppress_replaced_pane + AddWatcherClient(ClientId, Size), + RemoveWatcherClient(ClientId), + SetFollowedClient(ClientId), + WatcherTerminalResize(ClientId, Size), + ClearMouseHelpText(ClientId), + UpdateAvailableLayouts(Vec<LayoutInfo>, Vec<LayoutWithError>), + SetPluginRegexHighlights { + pane_id: PaneId, + plugin_id: u32, + highlights: Vec<RegexHighlight>, + }, + ClearPluginHighlights { + pane_id: PaneId, + 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>, + ansi: bool, + }, + NotifyPaneClosedToSubscribers { + pane_id: zellij_utils::data::PaneId, + }, + DesktopNotificationResponse(Vec<u8>, ClientId), + PluginSubscribedToAnsiPaneContents(bool), // true = at least one plugin needs ANSI content + UpdateBackgroundPluginSubscriptions(PluginId, ClientId, HashSet<EventType>), + ClearHintTextCache, + BroadcastModeUpdate(ModeInfo, Option<ClientId>), // ModeInfo, optional specific client_id (None = all clients) + // Pane-targeting CLI variants + ScrollUpWithPaneId(PaneId, Option<NotificationEnd>), + ScrollDownWithPaneId(PaneId, Option<NotificationEnd>), + ScrollToTopWithPaneId(PaneId, Option<NotificationEnd>), + ScrollToBottomWithPaneId(PaneId, Option<NotificationEnd>), + PageScrollUpWithPaneId(PaneId, Option<NotificationEnd>), + PageScrollDownWithPaneId(PaneId, Option<NotificationEnd>), + HalfPageScrollUpWithPaneId(PaneId, Option<NotificationEnd>), + HalfPageScrollDownWithPaneId(PaneId, Option<NotificationEnd>), + ResizeWithPaneId(PaneId, ResizeStrategy, Option<NotificationEnd>), + MovePaneWithPaneIdCli(PaneId, Option<Direction>, Option<NotificationEnd>), + MovePaneBackwardsWithPaneId(PaneId, Option<NotificationEnd>), + ClearScreenWithPaneId(PaneId, Option<NotificationEnd>), + EditScrollbackWithPaneId(PaneId, bool, Option<NotificationEnd>), + ToggleFullscreenWithPaneId(PaneId, Option<NotificationEnd>), + ToggleNoUiFullscreenWithPaneId(PaneId, Option<NotificationEnd>), + TogglePaneEmbedOrFloatingWithPaneId(PaneId, Option<NotificationEnd>), + CloseFocusWithPaneId(PaneId, Option<NotificationEnd>), + RenamePaneWithPaneId(PaneId, Vec<u8>, Option<NotificationEnd>), + UndoRenamePaneWithPaneId(PaneId, Option<NotificationEnd>), + TogglePanePinnedWithPaneId(PaneId, Option<NotificationEnd>), + // Tab-targeting CLI variants + UndoRenameTabWithTabId(usize, Option<NotificationEnd>), + ToggleActiveSyncTabWithTabId(usize, Option<NotificationEnd>), + ToggleFloatingPanesWithTabId(usize, Option<TerminalAction>, Option<NotificationEnd>), + PreviousSwapLayoutWithTabId(usize, Option<NotificationEnd>), + NextSwapLayoutWithTabId(usize, Option<NotificationEnd>), + MoveTabWithTabId(usize, Direction, Option<NotificationEnd>), + EnterMobileMode(ClientId, Option<NotificationEnd>), + ExitMobileMode(ClientId, Option<NotificationEnd>), + ToggleMobileMode(ClientId, Option<NotificationEnd>), + ReevaluateMobileMode { + client_id: ClientId, + new_size: Size, + mobile_layout: MobileLayoutConfiguration, + threshold_cols: u16, + threshold_rows: u16, + }, + SetSoftKeyboard { + client_id: ClientId, + on: bool, + }, + SetShadowFocus(ClientId, PaneId), + FocusHostSession(ClientId, Option<NotificationEnd>), + FocusGuestSession(ClientId, Option<NotificationEnd>), + ToggleHostFullscreen(ClientId, Option<NotificationEnd>), +} + +impl From<&ScreenInstruction> for ScreenContext { + fn from(screen_instruction: &ScreenInstruction) -> Self { + match *screen_instruction { + ScreenInstruction::PtyBytes(..) => ScreenContext::HandlePtyBytes, + ScreenInstruction::PluginBytes(..) => ScreenContext::PluginBytes, + ScreenInstruction::Render => ScreenContext::Render, + ScreenInstruction::RenderToClients => ScreenContext::RenderToClients, + ScreenInstruction::NewPane(..) => ScreenContext::NewPane, + ScreenInstruction::OpenInPlaceEditor(..) => ScreenContext::OpenInPlaceEditor, + ScreenInstruction::TogglePaneEmbedOrFloating(..) => { + ScreenContext::TogglePaneEmbedOrFloating + }, + ScreenInstruction::ToggleFloatingPanes(..) => ScreenContext::ToggleFloatingPanes, + ScreenInstruction::ShowFloatingPanes { .. } => ScreenContext::ShowFloatingPanes, + ScreenInstruction::HideFloatingPanes { .. } => ScreenContext::HideFloatingPanes, + ScreenInstruction::AreFloatingPanesVisible { .. } => { + ScreenContext::AreFloatingPanesVisible + }, + ScreenInstruction::WriteCharacter(..) => ScreenContext::WriteCharacter, + ScreenInstruction::Resize(.., strategy, _) => match strategy { + ResizeStrategy { + resize: Resize::Increase, + direction, + .. + } => match direction { + Some(Direction::Left) => ScreenContext::ResizeIncreaseLeft, + Some(Direction::Down) => ScreenContext::ResizeIncreaseDown, + Some(Direction::Up) => ScreenContext::ResizeIncreaseUp, + Some(Direction::Right) => ScreenContext::ResizeIncreaseRight, + None => ScreenContext::ResizeIncreaseAll, + }, + ResizeStrategy { + resize: Resize::Decrease, + direction, + .. + } => match direction { + Some(Direction::Left) => ScreenContext::ResizeDecreaseLeft, + Some(Direction::Down) => ScreenContext::ResizeDecreaseDown, + Some(Direction::Up) => ScreenContext::ResizeDecreaseUp, + Some(Direction::Right) => ScreenContext::ResizeDecreaseRight, + None => ScreenContext::ResizeDecreaseAll, + }, + }, + ScreenInstruction::SwitchFocus(..) => ScreenContext::SwitchFocus, + ScreenInstruction::FocusNextPane(..) => ScreenContext::FocusNextPane, + ScreenInstruction::FocusPreviousPane(..) => ScreenContext::FocusPreviousPane, + ScreenInstruction::FocusLastPane(..) => ScreenContext::FocusLastPane, + ScreenInstruction::MoveFocusLeft(..) => ScreenContext::MoveFocusLeft, + ScreenInstruction::MoveFocusLeftOrPreviousTab(..) => { + ScreenContext::MoveFocusLeftOrPreviousTab + }, + ScreenInstruction::MoveFocusDown(..) => ScreenContext::MoveFocusDown, + ScreenInstruction::MoveFocusUp(..) => ScreenContext::MoveFocusUp, + ScreenInstruction::MoveFocusRight(..) => ScreenContext::MoveFocusRight, + ScreenInstruction::MoveFocusRightOrNextTab(..) => { + ScreenContext::MoveFocusRightOrNextTab + }, + ScreenInstruction::MovePane(..) => ScreenContext::MovePane, + ScreenInstruction::MovePaneBackwards(..) => ScreenContext::MovePaneBackwards, + ScreenInstruction::MovePaneDown(..) => ScreenContext::MovePaneDown, + ScreenInstruction::MovePaneUp(..) => ScreenContext::MovePaneUp, + ScreenInstruction::MovePaneRight(..) => ScreenContext::MovePaneRight, + ScreenInstruction::MovePaneLeft(..) => ScreenContext::MovePaneLeft, + ScreenInstruction::Exit => ScreenContext::Exit, + ScreenInstruction::ClearScreen(..) => ScreenContext::ClearScreen, + ScreenInstruction::DumpScreen(..) => ScreenContext::DumpScreen, + ScreenInstruction::DumpLayout(..) => ScreenContext::DumpLayout, + ScreenInstruction::SaveSession(..) => ScreenContext::SaveSession, + ScreenInstruction::DumpLayoutToPlugin { .. } => ScreenContext::DumpLayoutToPlugin, + ScreenInstruction::GetFocusedPaneInfo { .. } => ScreenContext::GetFocusedPaneInfo, + ScreenInstruction::GetPaneInfo { .. } => ScreenContext::GetPaneInfo, + ScreenInstruction::GetTabInfo { .. } => ScreenContext::GetTabInfo, + ScreenInstruction::EditScrollback(..) => ScreenContext::EditScrollback, + ScreenInstruction::GetPaneScrollback { .. } => ScreenContext::GetPaneScrollback, + ScreenInstruction::ScrollUp(..) => ScreenContext::ScrollUp, + ScreenInstruction::ScrollDown(..) => ScreenContext::ScrollDown, + ScreenInstruction::ScrollToBottom(..) => ScreenContext::ScrollToBottom, + ScreenInstruction::ScrollToTop(..) => ScreenContext::ScrollToTop, + ScreenInstruction::PageScrollUp(..) => ScreenContext::PageScrollUp, + ScreenInstruction::PageScrollDown(..) => ScreenContext::PageScrollDown, + ScreenInstruction::HalfPageScrollUp(..) => ScreenContext::HalfPageScrollUp, + ScreenInstruction::HalfPageScrollDown(..) => ScreenContext::HalfPageScrollDown, + ScreenInstruction::ClearScroll(..) => ScreenContext::ClearScroll, + ScreenInstruction::CloseFocusedPane(..) => ScreenContext::CloseFocusedPane, + ScreenInstruction::ToggleActiveTerminalFullscreen(..) => { + ScreenContext::ToggleActiveTerminalFullscreen + }, + ScreenInstruction::ToggleActiveTerminalNoUiFullscreen(..) => { + ScreenContext::ToggleActiveTerminalNoUiFullscreen + }, + ScreenInstruction::TogglePaneFrames(..) => ScreenContext::TogglePaneFrames, + ScreenInstruction::SetPaneFrameStyle(..) => ScreenContext::SetPaneFrameStyle, + ScreenInstruction::SetSelectable(..) => ScreenContext::SetSelectable, + ScreenInstruction::ShowPluginCursor(..) => ScreenContext::ShowPluginCursor, + ScreenInstruction::ClosePane(..) => ScreenContext::ClosePane, + ScreenInstruction::HoldPane(..) => ScreenContext::HoldPane, + ScreenInstruction::UpdatePaneName(..) => ScreenContext::UpdatePaneName, + ScreenInstruction::UndoRenamePane(..) => ScreenContext::UndoRenamePane, + ScreenInstruction::NewTab(..) => ScreenContext::NewTab, + ScreenInstruction::ApplyLayout(..) => ScreenContext::ApplyLayout, + ScreenInstruction::SwitchTabNext(..) => ScreenContext::SwitchTabNext, + ScreenInstruction::SwitchTabPrev(..) => ScreenContext::SwitchTabPrev, + ScreenInstruction::CloseTab(..) => ScreenContext::CloseTab, + ScreenInstruction::GoToTab(..) => ScreenContext::GoToTab, + ScreenInstruction::GoToTabName(..) => ScreenContext::GoToTabName, + ScreenInstruction::UpdateTabName(..) => ScreenContext::UpdateTabName, + ScreenInstruction::UndoRenameTab(..) => ScreenContext::UndoRenameTab, + ScreenInstruction::MoveTabLeft(..) => ScreenContext::MoveTabLeft, + ScreenInstruction::MoveTabRight(..) => ScreenContext::MoveTabRight, + ScreenInstruction::GoToTabWithId(..) => ScreenContext::GoToTabWithId, + ScreenInstruction::CloseTabWithId(..) => ScreenContext::CloseTabWithId, + ScreenInstruction::RenameTabWithId(..) => ScreenContext::RenameTabWithId, + ScreenInstruction::BreakPanesToTabWithId { .. } => ScreenContext::BreakPanesToTabWithId, + ScreenInstruction::TerminalResize(..) => ScreenContext::TerminalResize, + ScreenInstruction::RecomputeTabSize(..) => ScreenContext::RecomputeTabSize, + ScreenInstruction::TerminalPixelDimensions(..) => { + ScreenContext::TerminalPixelDimensions + }, + ScreenInstruction::TerminalBackgroundColor(..) => { + ScreenContext::TerminalBackgroundColor + }, + ScreenInstruction::TerminalForegroundColor(..) => { + ScreenContext::TerminalForegroundColor + }, + ScreenInstruction::TerminalColorRegisters(..) => ScreenContext::TerminalColorRegisters, + ScreenInstruction::SetKittyGraphicsSupport { .. } => { + ScreenContext::SetKittyGraphicsSupport + }, + ScreenInstruction::ForwardHostQuery { .. } => ScreenContext::ForwardHostQuery, + ScreenInstruction::NestedSessionMessageFromPane { .. } => { + ScreenContext::NestedSessionMessageFromPane + }, + ScreenInstruction::NestedGuestPingTick { .. } => ScreenContext::NestedGuestPingTick, + ScreenInstruction::NestedSessionMessageFromHost { .. } => { + ScreenContext::NestedSessionMessageFromHost + }, + ScreenInstruction::GuestModalChoice { .. } => ScreenContext::GuestModalChoice, + ScreenInstruction::ForwardedReplyFromHost { .. } => { + ScreenContext::ForwardedReplyFromHost + }, + ScreenInstruction::ResumePaneAfterForward { .. } => { + ScreenContext::ResumePaneAfterForward + }, + ScreenInstruction::HostTerminalThemeChanged(..) => { + ScreenContext::HostTerminalThemeChanged + }, + ScreenInstruction::SetDarkTheme(..) => ScreenContext::SetDarkTheme, + ScreenInstruction::SetLightTheme(..) => ScreenContext::SetLightTheme, + ScreenInstruction::ToggleTheme(..) => ScreenContext::ToggleTheme, + ScreenInstruction::ChangeMode(..) => ScreenContext::ChangeMode, + ScreenInstruction::ChangeModeForAllClients(..) => { + ScreenContext::ChangeModeForAllClients + }, + ScreenInstruction::ToggleActiveSyncTab(..) => ScreenContext::ToggleActiveSyncTab, + ScreenInstruction::ScrollUpAt(..) => ScreenContext::ScrollUpAt, + ScreenInstruction::ScrollDownAt(..) => ScreenContext::ScrollDownAt, + ScreenInstruction::MouseEvent(..) => ScreenContext::MouseEvent, + ScreenInstruction::Copy(..) => ScreenContext::Copy, + ScreenInstruction::ToggleTab(..) => ScreenContext::ToggleTab, + ScreenInstruction::AddClient(..) => ScreenContext::AddClient, + ScreenInstruction::RemoveClient(..) => ScreenContext::RemoveClient, + ScreenInstruction::SuppressRenderUntilMobile(..) => { + ScreenContext::SuppressRenderUntilMobile + }, + ScreenInstruction::MobileSizeSettled(..) => ScreenContext::MobileSizeSettled, + ScreenInstruction::ForceMobileUngate(..) => ScreenContext::ForceMobileUngate, + ScreenInstruction::UpdateSearch(..) => ScreenContext::UpdateSearch, + ScreenInstruction::SearchDown(..) => ScreenContext::SearchDown, + ScreenInstruction::SearchUp(..) => ScreenContext::SearchUp, + ScreenInstruction::SearchToggleCaseSensitivity(..) => { + ScreenContext::SearchToggleCaseSensitivity + }, + ScreenInstruction::SearchToggleWholeWord(..) => ScreenContext::SearchToggleWholeWord, + ScreenInstruction::SearchToggleWrap(..) => ScreenContext::SearchToggleWrap, + ScreenInstruction::AddRedPaneFrameColorOverride(..) => { + ScreenContext::AddRedPaneFrameColorOverride + }, + ScreenInstruction::ClearPaneFrameColorOverride(..) => { + ScreenContext::ClearPaneFrameColorOverride + }, + ScreenInstruction::SetTabBellFlash(..) => ScreenContext::SetTabBellFlash, + ScreenInstruction::PreviousSwapLayout(..) => ScreenContext::PreviousSwapLayout, + ScreenInstruction::NextSwapLayout(..) => ScreenContext::NextSwapLayout, + ScreenInstruction::OverrideLayout(..) => ScreenContext::OverrideLayout, + ScreenInstruction::OverrideLayoutComplete(..) => ScreenContext::OverrideLayoutComplete, + ScreenInstruction::QueryTabNames(..) => ScreenContext::QueryTabNames, + ScreenInstruction::NewTiledPluginPane(..) => ScreenContext::NewTiledPluginPane, + ScreenInstruction::NewFloatingPluginPane(..) => ScreenContext::NewFloatingPluginPane, + ScreenInstruction::StartOrReloadPluginPane(..) => { + ScreenContext::StartOrReloadPluginPane + }, + ScreenInstruction::AddPlugin(..) => ScreenContext::AddPlugin, + ScreenInstruction::UpdatePluginLoadingStage(..) => { + ScreenContext::UpdatePluginLoadingStage + }, + ScreenInstruction::ProgressPluginLoadingOffset(..) => { + ScreenContext::ProgressPluginLoadingOffset + }, + ScreenInstruction::StartPluginLoadingIndication(..) => { + ScreenContext::StartPluginLoadingIndication + }, + ScreenInstruction::RequestStateUpdateForPlugins => { + ScreenContext::RequestStateUpdateForPlugins + }, + ScreenInstruction::LaunchOrFocusPlugin(..) => ScreenContext::LaunchOrFocusPlugin, + ScreenInstruction::LaunchPlugin(..) => ScreenContext::LaunchPlugin, + ScreenInstruction::SuppressPane(..) => ScreenContext::SuppressPane, + ScreenInstruction::UnsuppressPane(..) => ScreenContext::UnsuppressPane, + ScreenInstruction::UnsuppressOrExpandPane(..) => ScreenContext::UnsuppressOrExpandPane, + ScreenInstruction::FocusPaneWithId(..) => ScreenContext::FocusPaneWithId, + ScreenInstruction::RenamePane(..) => ScreenContext::RenamePane, + ScreenInstruction::RenameActivePane(..) => ScreenContext::RenameActivePane, + ScreenInstruction::RenameTab(..) => ScreenContext::RenameTab, + ScreenInstruction::RequestPluginPermissions(..) => { + ScreenContext::RequestPluginPermissions + }, + ScreenInstruction::BreakPane(..) => ScreenContext::BreakPane, + ScreenInstruction::BreakPaneRight(..) => ScreenContext::BreakPaneRight, + ScreenInstruction::BreakPaneLeft(..) => ScreenContext::BreakPaneLeft, + ScreenInstruction::UpdateSessionInfos(..) => ScreenContext::UpdateSessionInfos, + ScreenInstruction::ReplacePane(..) => ScreenContext::ReplacePane, + ScreenInstruction::NewInPlacePluginPane(..) => ScreenContext::NewInPlacePluginPane, + ScreenInstruction::SerializeLayoutForResurrection => { + ScreenContext::SerializeLayoutForResurrection + }, + ScreenInstruction::RenameSession(..) => ScreenContext::RenameSession, + ScreenInstruction::ListClientsMetadata(..) => ScreenContext::ListClientsMetadata, + ScreenInstruction::ListPanes { .. } => ScreenContext::ListPanes, + ScreenInstruction::ListTabs { .. } => ScreenContext::ListTabs, + ScreenInstruction::GetCurrentTabInfo { .. } => ScreenContext::GetCurrentTabInfo, + ScreenInstruction::Reconfigure { .. } => ScreenContext::Reconfigure, + ScreenInstruction::RerunCommandPane { .. } => ScreenContext::RerunCommandPane, + ScreenInstruction::ResizePaneWithId(..) => ScreenContext::ResizePaneWithId, + ScreenInstruction::EditScrollbackForPaneWithId(..) => { + ScreenContext::EditScrollbackForPaneWithId + }, + ScreenInstruction::WriteToPaneId(..) => ScreenContext::WriteToPaneId, + ScreenInstruction::Paste(..) => ScreenContext::Paste, + ScreenInstruction::SetPaneColor(..) => ScreenContext::SetPaneColor, + ScreenInstruction::WriteKeyToPaneId(..) => ScreenContext::WriteKeyToPaneId, + ScreenInstruction::CopyTextToClipboard(..) => ScreenContext::CopyTextToClipboard, + ScreenInstruction::MovePaneWithPaneId(..) => ScreenContext::MovePaneWithPaneId, + ScreenInstruction::MovePaneWithPaneIdInDirection(..) => { + ScreenContext::MovePaneWithPaneIdInDirection + }, + ScreenInstruction::ClearScreenForPaneId(..) => ScreenContext::ClearScreenForPaneId, + ScreenInstruction::ScrollUpInPaneId(..) => ScreenContext::ScrollUpInPaneId, + ScreenInstruction::ScrollDownInPaneId(..) => ScreenContext::ScrollDownInPaneId, + ScreenInstruction::ScrollToTopInPaneId(..) => ScreenContext::ScrollToTopInPaneId, + ScreenInstruction::ScrollToBottomInPaneId(..) => ScreenContext::ScrollToBottomInPaneId, + ScreenInstruction::PageScrollUpInPaneId(..) => ScreenContext::PageScrollUpInPaneId, + ScreenInstruction::PageScrollDownInPaneId(..) => ScreenContext::PageScrollDownInPaneId, + ScreenInstruction::TogglePaneIdFullscreen(..) => ScreenContext::TogglePaneIdFullscreen, + ScreenInstruction::SetTabFit { .. } => ScreenContext::SetTabFit, + ScreenInstruction::TogglePaneEmbedOrEjectForPaneId(..) => { + ScreenContext::TogglePaneEmbedOrEjectForPaneId + }, + ScreenInstruction::CloseTabWithIndex(..) => ScreenContext::CloseTabWithIndex, + ScreenInstruction::BreakPanesToNewTab { .. } => ScreenContext::BreakPanesToNewTab, + ScreenInstruction::BreakPanesToTabWithIndex { .. } => { + ScreenContext::BreakPanesToTabWithIndex + }, + ScreenInstruction::ListClientsToPlugin(..) => ScreenContext::ListClientsToPlugin, + ScreenInstruction::TogglePanePinned(..) => ScreenContext::TogglePanePinned, + ScreenInstruction::SetFloatingPanePinned(..) => ScreenContext::SetFloatingPanePinned, + ScreenInstruction::StackPanes(..) => ScreenContext::StackPanes, + ScreenInstruction::ChangeFloatingPanesCoordinates(..) => { + ScreenContext::ChangeFloatingPanesCoordinates + }, + ScreenInstruction::TogglePaneBorderless(..) => ScreenContext::TogglePaneBorderless, + ScreenInstruction::SetPaneBorderless(..) => ScreenContext::SetPaneBorderless, + ScreenInstruction::AddHighlightPaneFrameColorOverride(..) => { + ScreenContext::AddHighlightPaneFrameColorOverride + }, + ScreenInstruction::GroupAndUngroupPanes(..) => ScreenContext::GroupAndUngroupPanes, + ScreenInstruction::HighlightAndUnhighlightPanes(..) => { + ScreenContext::HighlightAndUnhighlightPanes + }, + ScreenInstruction::FloatMultiplePanes(..) => ScreenContext::FloatMultiplePanes, + ScreenInstruction::EmbedMultiplePanes(..) => ScreenContext::EmbedMultiplePanes, + ScreenInstruction::TogglePaneInGroup(..) => ScreenContext::TogglePaneInGroup, + ScreenInstruction::ToggleGroupMarking(..) => ScreenContext::ToggleGroupMarking, + ScreenInstruction::SessionSharingStatusChange(..) => { + ScreenContext::SessionSharingStatusChange + }, + ScreenInstruction::SetMouseSelectionSupport(..) => { + ScreenContext::SetMouseSelectionSupport + }, + ScreenInstruction::InterceptKeyPresses(..) => ScreenContext::InterceptKeyPresses, + ScreenInstruction::ClearKeyPressesIntercepts(..) => { + ScreenContext::ClearKeyPressesIntercepts + }, + ScreenInstruction::ReplacePaneWithExistingPane(..) => { + ScreenContext::ReplacePaneWithExistingPane + }, + ScreenInstruction::AddWatcherClient(..) => ScreenContext::AddWatcherClient, + ScreenInstruction::RemoveWatcherClient(..) => ScreenContext::RemoveWatcherClient, + ScreenInstruction::SetFollowedClient(..) => ScreenContext::SetFollowedClient, + ScreenInstruction::WatcherTerminalResize(..) => ScreenContext::WatcherTerminalResize, + ScreenInstruction::ClearMouseHelpText(..) => ScreenContext::ClearMouseHelpText, + ScreenInstruction::UpdateAvailableLayouts(..) => ScreenContext::UpdateAvailableLayouts, + ScreenInstruction::SetPluginRegexHighlights { .. } => { + ScreenContext::SetPluginRegexHighlights + }, + ScreenInstruction::ClearPluginHighlights { .. } => ScreenContext::ClearPluginHighlights, + ScreenInstruction::ClearAllPluginHighlights(..) => ScreenContext::ClearPluginHighlights, + ScreenInstruction::DesktopNotificationResponse(..) => { + ScreenContext::DesktopNotificationResponse + }, + ScreenInstruction::SubscribeToPaneRenders { .. } => { + ScreenContext::SubscribeToPaneRenders + }, + ScreenInstruction::NotifyPaneClosedToSubscribers { .. } => { + ScreenContext::NotifyPaneClosedToSubscribers + }, + ScreenInstruction::PluginSubscribedToAnsiPaneContents(..) => { + ScreenContext::PluginSubscribedToAnsiPaneContents + }, + ScreenInstruction::UpdateBackgroundPluginSubscriptions(..) => { + ScreenContext::UpdateBackgroundPluginSubscriptions + }, + ScreenInstruction::ClearHintTextCache => ScreenContext::ClearHintTextCache, + ScreenInstruction::BroadcastModeUpdate(..) => ScreenContext::BroadcastModeUpdate, + // Pane-targeting CLI variants + ScreenInstruction::ScrollUpWithPaneId(..) => ScreenContext::ScrollUpWithPaneId, + ScreenInstruction::ScrollDownWithPaneId(..) => ScreenContext::ScrollDownWithPaneId, + ScreenInstruction::ScrollToTopWithPaneId(..) => ScreenContext::ScrollToTopWithPaneId, + ScreenInstruction::ScrollToBottomWithPaneId(..) => { + ScreenContext::ScrollToBottomWithPaneId + }, + ScreenInstruction::PageScrollUpWithPaneId(..) => ScreenContext::PageScrollUpWithPaneId, + ScreenInstruction::PageScrollDownWithPaneId(..) => { + ScreenContext::PageScrollDownWithPaneId + }, + ScreenInstruction::HalfPageScrollUpWithPaneId(..) => { + ScreenContext::HalfPageScrollUpWithPaneId + }, + ScreenInstruction::HalfPageScrollDownWithPaneId(..) => { + ScreenContext::HalfPageScrollDownWithPaneId + }, + ScreenInstruction::ResizeWithPaneId(..) => ScreenContext::ResizeWithPaneId, + ScreenInstruction::MovePaneWithPaneIdCli(..) => ScreenContext::MovePaneWithPaneIdCli, + ScreenInstruction::MovePaneBackwardsWithPaneId(..) => { + ScreenContext::MovePaneBackwardsWithPaneId + }, + ScreenInstruction::ClearScreenWithPaneId(..) => ScreenContext::ClearScreenWithPaneId, + ScreenInstruction::EditScrollbackWithPaneId(..) => { + ScreenContext::EditScrollbackWithPaneId + }, + ScreenInstruction::ToggleFullscreenWithPaneId(..) => { + ScreenContext::ToggleFullscreenWithPaneId + }, + ScreenInstruction::ToggleNoUiFullscreenWithPaneId(..) => { + ScreenContext::ToggleNoUiFullscreenWithPaneId + }, + ScreenInstruction::TogglePaneEmbedOrFloatingWithPaneId(..) => { + ScreenContext::TogglePaneEmbedOrFloatingWithPaneId + }, + ScreenInstruction::CloseFocusWithPaneId(..) => ScreenContext::CloseFocusWithPaneId, + ScreenInstruction::RenamePaneWithPaneId(..) => ScreenContext::RenamePaneWithPaneId, + ScreenInstruction::UndoRenamePaneWithPaneId(..) => { + ScreenContext::UndoRenamePaneWithPaneId + }, + ScreenInstruction::TogglePanePinnedWithPaneId(..) => { + ScreenContext::TogglePanePinnedWithPaneId + }, + // Tab-targeting CLI variants + ScreenInstruction::UndoRenameTabWithTabId(..) => ScreenContext::UndoRenameTabWithTabId, + ScreenInstruction::ToggleActiveSyncTabWithTabId(..) => { + ScreenContext::ToggleActiveSyncTabWithTabId + }, + ScreenInstruction::ToggleFloatingPanesWithTabId(..) => { + ScreenContext::ToggleFloatingPanesWithTabId + }, + ScreenInstruction::PreviousSwapLayoutWithTabId(..) => { + ScreenContext::PreviousSwapLayoutWithTabId + }, + ScreenInstruction::NextSwapLayoutWithTabId(..) => { + ScreenContext::NextSwapLayoutWithTabId + }, + ScreenInstruction::MoveTabWithTabId(..) => ScreenContext::MoveTabWithTabId, + ScreenInstruction::EnterMobileMode(..) => ScreenContext::EnterMobileMode, + ScreenInstruction::ExitMobileMode(..) => ScreenContext::ExitMobileMode, + ScreenInstruction::ToggleMobileMode(..) => ScreenContext::ToggleMobileMode, + ScreenInstruction::ReevaluateMobileMode { .. } => ScreenContext::ReevaluateMobileMode, + ScreenInstruction::SetSoftKeyboard { .. } => ScreenContext::SetSoftKeyboard, + ScreenInstruction::SetShadowFocus(..) => ScreenContext::SetShadowFocus, + ScreenInstruction::FocusHostSession(..) => ScreenContext::FocusHostSession, + ScreenInstruction::FocusGuestSession(..) => ScreenContext::FocusGuestSession, + ScreenInstruction::ToggleHostFullscreen(..) => ScreenContext::ToggleHostFullscreen, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct CopyOptions { + pub command: Option<String>, + pub clipboard: Clipboard, + pub copy_on_select: bool, +} + +impl CopyOptions { + pub(crate) fn new( + copy_command: Option<String>, + copy_clipboard: Clipboard, + copy_on_select: bool, + ) -> Self { + Self { + command: copy_command, + clipboard: copy_clipboard, + copy_on_select, + } + } + + #[cfg(test)] + pub(crate) fn default() -> Self { + Self { + command: None, + clipboard: Clipboard::default(), + copy_on_select: true, + } + } +} + +// We use this to delay rendering when a new tab opens so that we make sure all plugins +// (representing portions of the UI) have been fully loaded before the tab is first rendered (with +// a sensible timeout of 100ms) +#[derive(Debug, Clone)] +pub struct RenderBlocker { + blocking_plugins: HashMap<u32, Instant>, + #[cfg_attr(test, allow(dead_code))] + timeout_ms: u64, +} + +impl RenderBlocker { + pub fn new(timeout_ms: u64) -> Self { + Self { + blocking_plugins: HashMap::new(), + timeout_ms, + } + } + + pub fn register_blocking_plugin(&mut self, plugin_id: u32) { + self.blocking_plugins.insert(plugin_id, Instant::now()); + } + + pub fn remove_blocking_plugin(&mut self, plugin_id: u32) { + self.blocking_plugins.remove(&plugin_id); + } + + #[cfg(test)] + pub fn can_render(&mut self) -> bool { + // we want the tests to be more deterministic and so we always render without any + // optimizations + true + } + + #[cfg(not(test))] + pub fn can_render(&mut self) -> bool { + let ret = if self.blocking_plugins.is_empty() { + true + } else { + let timeout = Duration::from_millis(self.timeout_ms); + let now = Instant::now(); + + self.blocking_plugins + .values() + .all(|®istered_at| now.duration_since(registered_at) >= timeout) + }; + if ret { + self.blocking_plugins.clear(); + } + ret + } +} + +#[derive(Debug, Clone)] +pub(crate) struct WatcherState { + size: Size, + should_force_render: bool, +} + +impl WatcherState { + pub fn new(size: Size) -> Self { + WatcherState { + size, + should_force_render: true, + } + } + + pub fn size(&self) -> Size { + self.size + } + + pub fn set_size(&mut self, size: Size) { + self.size = size; + } + + pub fn should_force_render(&self) -> bool { + self.should_force_render + } + + pub fn clear_force_render(&mut self) { + self.should_force_render = false; + } + + pub fn set_force_render(&mut self) { + self.should_force_render = true; + } +} + +struct PaneRenderSubscription { + pane_ids: HashSet<zellij_utils::data::PaneId>, + previous_viewports: HashMap<zellij_utils::data::PaneId, Vec<String>>, + ansi: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NestedGuestChoice { + Zoom, + Descend, + Dismissed, +} + +impl NestedGuestChoice { + fn enters_passthrough(&self) -> bool { + matches!(self, NestedGuestChoice::Zoom | NestedGuestChoice::Descend) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuestModalOutcome { + Zoom, + Descend, +} + +fn format_guest_modal_shortcut(keys: &[KeyWithModifier]) -> Vec<String> { + keys.iter().map(|key| key.to_string()).collect() +} + +/// 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 { + /// A Bus for sending and receiving messages with the other threads. + pub bus: Bus<ScreenInstruction>, + /// An optional maximal amount of panes allowed per [`Tab`] in this [`Screen`] instance. + max_panes: Option<usize>, + /// A map between this [`Screen`]'s tabs and their ID/key. + tabs: BTreeMap<usize, Tab>, + last_single_pane_tab_names: HashMap<usize, Option<String>>, + /// The full size of this [`Screen`]. + size: Size, + pixel_dimensions: PixelDimensions, + character_cell_size: Rc<RefCell<Option<SizeInPixels>>>, + stacked_resize: Rc<RefCell<bool>>, + stacked_pane_list: Rc<RefCell<bool>>, + sixel_image_store: Rc<RefCell<SixelImageStore>>, + kitty_image_store: Rc<RefCell<KittyImageStore>>, + kitty_host_capabilities: Rc<RefCell<HashMap<ClientId, bool>>>, + client_kitty_host_state: Rc<RefCell<HashMap<ClientId, HostKittyState>>>, + terminal_emulator_colors: Rc<RefCell<Palette>>, + terminal_emulator_color_codes: Rc<RefCell<HashMap<usize, String>>>, + connected_clients: Rc<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client + /// The indices of this [`Screen`]'s active [`Tab`]s. + active_tab_ids: BTreeMap<ClientId, usize>, + client_sizes: HashMap<ClientId, Size>, + global_last_active_tab_id: usize, + tab_history: BTreeMap<ClientId, Vec<usize>>, + pane_history: BTreeMap<ClientId, Vec<PaneId>>, + mode_info: BTreeMap<ClientId, ModeInfo>, + default_mode_info: ModeInfo, // TODO: restructure ModeInfo to prevent this duplication + style: Style, + pane_frame_style: PaneFrameStyle, + auto_layout: bool, + session_serialization: bool, + serialize_pane_viewport: bool, + scrollback_lines_to_serialize: Option<usize>, + session_is_mirrored: bool, + copy_options: CopyOptions, + debug: bool, + session_name: String, + peer_sessions_cache: BTreeMap<String, SessionInfo>, // String is the session name, can + // also be this session + 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, + osc8_hyperlinks: bool, + arrow_fonts: bool, + #[cfg_attr(test, allow(dead_code))] + layout_dir: Option<PathBuf>, + #[cfg_attr(test, allow(dead_code))] + default_layout_name: Option<String>, + explicitly_disable_kitty_keyboard_protocol: bool, + support_kitty_graphics_protocol: bool, + default_editor: Option<PathBuf>, + web_clients_allowed: bool, + web_sharing: WebSharing, + current_pane_group: Rc<RefCell<PaneGroups>>, + advanced_mouse_actions: bool, + mouse_scroll_resize: bool, + mouse_hover_effects: bool, + visual_bell: bool, + focus_follows_mouse: bool, + mouse_click_through: bool, + currently_marking_pane_group: Rc<RefCell<HashMap<ClientId, bool>>>, + // the below are the configured values - the ones that will be set if and when the web server + // is brought online + web_server_ip: IpAddr, + web_server_port: u16, + render_blocker: RenderBlocker, + watcher_clients: HashMap<ClientId, WatcherState>, + followed_client_id: Option<ClientId>, + cached_layouts: Vec<LayoutInfo>, + cached_layout_errors: Vec<LayoutWithError>, + pane_render_subscribers: HashMap<ClientId, PaneRenderSubscription>, + plugins_need_ansi_pane_contents: bool, + background_plugin_subscriptions: HashMap<(PluginId, ClientId), HashSet<EventType>>, + next_forward_token: u32, + pending_forwarded_queries: HashMap<u32, PendingForwardEntry>, + forward_queue: VecDeque<PendingForward>, + forward_in_flight_token: Option<u32>, + nested_guest_tracker: NestedGuestTracker, + nested_ancestry: Vec<String>, + nested_via_client_id: Option<ClientId>, + nested_fullscreen_panes: HashSet<PaneId>, + own_fullscreen_requested: bool, + reported_guest_fullscreen: HashMap<PaneId, bool>, + host_fullscreen: bool, + dimmed_clients: HashSet<ClientId>, + nested_guest_choices: HashMap<(ClientId, PaneId), NestedGuestChoice>, + guest_ascend_keys: HashMap<PaneId, Vec<KeyWithModifier>>, + host_descend_keys: Vec<KeyWithModifier>, + host_descended: bool, + host_terminal_theme_mode: Option<HostTerminalThemeMode>, + host_theme_dark_styling: Option<Styling>, + host_theme_light_styling: Option<Styling>, + nested_session_handling: NestedSessionHandling, + mobile_state: MobileState, + mobile_render_gate: MobileRenderGate, +} + +/// A pending forward waiting to be dispatched once the current in-flight +/// forward's barrier reply (or timeout) arrives. +#[derive(Debug, Clone)] +struct PendingForward { + token: u32, + pane_id: PaneId, + query: crate::host_query::HostQuery, +} + +/// A forward currently in flight (dispatched to the client, waiting +/// for a reply). Retains the `HostQuery` classification so we can +/// fall back to cache-synthesized replies when the live reply is +/// empty (no client attached, client-side timeout, etc.) without +/// re-parsing byte strings. +#[derive(Debug, Clone)] +struct PendingForwardEntry { + pane_id: PaneId, + query: crate::host_query::HostQuery, +} + +/// Reserved sentinel token for Zellij's own startup batch of host +/// queries (pixel dims, bg/fg, sync-output, palette registers). The +/// existing fire-and-forget startup plumbing is unchanged today; the +/// sentinel exists so a future migration can route the startup batch +/// through the same forwarded-query mechanism as per-pane queries +/// while keeping its replies routable to `Screen`'s cached state +/// rather than to a pane's pty. Reserved value 0 is excluded from +/// `next_forward_token` allocation by the wrap-skip in +/// `forward_host_query`. +const STARTUP_SENTINEL_TOKEN: u32 = 0; + +/// Server-side deadline for a forwarded host query. If no +/// `ForwardedReplyFromHost` arrives within this window, the server +/// synthesizes an empty reply for itself so the in-flight slot +/// releases and queued forwards continue to drain. +/// +/// This recovers the worst-case where the connected client doesn't +/// understand `ServerToClientMsg::ForwardQueryToHost` (an old client +/// against a new server) — without it the slot would deadlock and +/// every app-issued host query would hang. The window is generous +/// relative to the 500 ms client-side deadline so a well-behaved new +/// client always replies first; only the old-client and +/// network-pathological cases ever see this fire. +const SERVER_FORWARD_TIMEOUT_MS: u64 = 1000; + +impl Screen { + /// Creates and returns a new [`Screen`]. + pub fn new( + bus: Bus<ScreenInstruction>, + client_attributes: &ClientAttributes, + max_panes: Option<usize>, + mode_info: ModeInfo, + pane_frame_style: PaneFrameStyle, + auto_layout: bool, + session_is_mirrored: bool, + copy_options: CopyOptions, + debug: bool, + default_layout: Box<Layout>, + default_layout_name: Option<String>, + default_shell: PathBuf, + session_serialization: bool, + serialize_pane_viewport: bool, + scrollback_lines_to_serialize: Option<usize>, + styled_underlines: bool, + osc8_hyperlinks: bool, + arrow_fonts: bool, + layout_dir: Option<PathBuf>, + explicitly_disable_kitty_keyboard_protocol: bool, + support_kitty_graphics_protocol: bool, + stacked_resize: bool, + stacked_pane_list: bool, + default_editor: Option<PathBuf>, + web_clients_allowed: bool, + web_sharing: WebSharing, + advanced_mouse_actions: bool, + mouse_scroll_resize: bool, + mouse_hover_effects: bool, + visual_bell: bool, + focus_follows_mouse: bool, + mouse_click_through: bool, + web_server_ip: IpAddr, + web_server_port: u16, + nested_session_handling: NestedSessionHandling, + ) -> Self { + let session_name = mode_info.session_name.clone().unwrap_or_default(); + let session_info = SessionInfo::new(session_name.clone()); + 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, + max_panes, + size: client_attributes.size, + pixel_dimensions: Default::default(), + character_cell_size: Rc::new(RefCell::new(None)), + stacked_resize: Rc::new(RefCell::new(stacked_resize)), + stacked_pane_list: Rc::new(RefCell::new(stacked_pane_list)), + sixel_image_store: Rc::new(RefCell::new(SixelImageStore::default())), + kitty_image_store: Rc::new(RefCell::new(KittyImageStore::default())), + kitty_host_capabilities: Rc::new(RefCell::new(HashMap::new())), + client_kitty_host_state: Rc::new(RefCell::new(HashMap::new())), + style: client_attributes.style, + connected_clients: Rc::new(RefCell::new(HashMap::new())), + active_tab_ids: BTreeMap::new(), + client_sizes: HashMap::new(), + global_last_active_tab_id: 0, + tabs: BTreeMap::new(), + last_single_pane_tab_names: HashMap::new(), + terminal_emulator_colors: Rc::new(RefCell::new(Palette::default())), + terminal_emulator_color_codes: Rc::new(RefCell::new(HashMap::new())), + tab_history: BTreeMap::new(), + pane_history: BTreeMap::new(), + mode_info: BTreeMap::new(), + default_mode_info: mode_info, + pane_frame_style, + auto_layout, + session_is_mirrored, + copy_options, + debug, + session_name, + peer_sessions_cache, + default_layout, + default_layout_name, + default_shell, + session_serialization, + serialize_pane_viewport, + scrollback_lines_to_serialize, + styled_underlines, + osc8_hyperlinks, + arrow_fonts, + resurrectable_sessions_cache, + layout_dir, + explicitly_disable_kitty_keyboard_protocol, + support_kitty_graphics_protocol, + default_editor, + web_clients_allowed, + web_sharing, + current_pane_group: Rc::new(RefCell::new(current_pane_group)), + currently_marking_pane_group: Rc::new(RefCell::new(HashMap::new())), + advanced_mouse_actions, + mouse_scroll_resize, + mouse_hover_effects, + visual_bell, + focus_follows_mouse, + mouse_click_through, + web_server_ip, + web_server_port, + render_blocker: RenderBlocker::new(100), + watcher_clients: HashMap::new(), + followed_client_id: None, + cached_layouts: vec![], + cached_layout_errors: vec![], + pane_render_subscribers: HashMap::new(), + plugins_need_ansi_pane_contents: false, + background_plugin_subscriptions: HashMap::new(), + next_forward_token: 1, // 0 is reserved as the startup sentinel + pending_forwarded_queries: HashMap::new(), + forward_queue: VecDeque::new(), + forward_in_flight_token: None, + nested_guest_tracker: NestedGuestTracker::default(), + nested_ancestry: vec![], + nested_via_client_id: None, + nested_fullscreen_panes: HashSet::new(), + own_fullscreen_requested: false, + reported_guest_fullscreen: HashMap::new(), + host_fullscreen: false, + dimmed_clients: HashSet::new(), + nested_guest_choices: HashMap::new(), + guest_ascend_keys: HashMap::new(), + host_descend_keys: vec![], + host_descended: false, + host_terminal_theme_mode: None, + host_theme_dark_styling: None, + host_theme_light_styling: None, + mobile_state: MobileState::default(), + nested_session_handling, + mobile_render_gate: MobileRenderGate::default(), + } + } + + fn client_is_web(&self, client_id: ClientId) -> bool { + self.connected_clients + .borrow() + .get(&client_id) + .copied() + .unwrap_or(false) + } + + fn try_lift_mobile_gate(&mut self, client_id: ClientId) -> bool { + let is_web_client = self.client_is_web(client_id); + let reported_size = self.client_sizes.get(&client_id).copied(); + let lifted = self + .mobile_render_gate + .try_reveal(client_id, is_web_client, reported_size); + if lifted { + self.force_render_mobile_tab(client_id); + } + lifted + } + + fn force_render_mobile_tab(&mut self, client_id: ClientId) { + if let Some(tab_id) = self.mobile_state.mobile_tab_id(client_id) { + if let Some(tab) = self.tabs.get_mut(&tab_id) { + tab.set_should_clear_display_before_rendering(); + tab.set_force_render(); + } + } + } + + fn ungate_clients_for_mobile_plugin(&mut self, plugin_id: u32) { + if self.mobile_render_gate.is_empty() { + return; + } + let Some(tab_id) = self + .tabs + .values() + .find(|tab| tab.has_plugin(plugin_id)) + .map(|tab| tab.id) + else { + return; + }; + for client_id in self.mobile_render_gate.gated_clients() { + if self.mobile_state.mobile_tab_id(client_id) == Some(tab_id) { + self.mobile_render_gate.ungate(client_id); + } + } + } + + fn get_new_tab_id(&self) -> usize { + if let Some(id) = self.tabs.keys().last() { + *id + 1 + } else { + 0 + } + } + + /// Gets a tab by its stable ID (BTreeMap key). + /// + /// Use this when you have a tab ID from active_tab_ids, tab_history, or tab.id. + fn get_tab_by_id(&self, id: usize) -> Option<&Tab> { + self.tabs.get(&id) + } + + /// Gets a mutable tab by its stable ID (BTreeMap key). + fn get_tab_by_id_mut(&mut self, id: usize) -> Option<&mut Tab> { + self.tabs.get_mut(&id) + } + + /// Gets a mutable tab by its display position (0-based). + fn get_tab_by_position_mut(&mut self, position: usize) -> Option<&mut Tab> { + self.tabs.values_mut().find(|t| t.position == position) + } + + /// Gets the stable ID of the tab at the given display position. + /// + /// Use this to convert position → ID for BTreeMap lookups. + fn get_tab_id_at_position(&self, position: usize) -> Option<usize> { + self.tabs + .values() + .find(|t| t.position == position) + .map(|t| t.id) + } + + /// Gets the display position of the tab with the given ID. + /// + /// Use this to convert ID → position for user-facing operations. + fn get_tab_position_by_id(&self, id: usize) -> Option<usize> { + self.tabs.get(&id).map(|t| t.position) + } + + pub fn tab_visible_to(&self, client_id: ClientId, tab_id: usize) -> bool { + match self.tabs.get(&tab_id) { + Some(tab) => match &tab.visible_to { + None => true, + Some(set) => set.contains(&client_id), + }, + None => false, + } + } + + pub fn is_in_mobile_mode(&self, client_id: ClientId) -> bool { + self.mobile_state.is_in_mobile_mode(client_id) + } + + pub(crate) fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) -> Result<()> { + if let ShadowFocusOutcome::NewlyApplied = + self.mobile_state + .apply_shadow_focus(client_id, pane_id, &mut self.tabs) + { + self.log_and_report_session_state()?; + self.render(None)?; + } + Ok(()) + } + + pub fn enter_mobile_mode(&mut self, client_id: ClientId) -> Result<()> { + let err_context = || format!("failed to enter mobile mode for client {client_id}"); + + if self.mobile_state.is_in_mobile_mode(client_id) { + return Ok(()); + } + + let prior = self.active_tab_ids.get(&client_id).copied(); + self.mobile_state.set_previous_tab(client_id, prior); + + self.mobile_state + .clear_shadow_focus(client_id, &mut self.tabs); + + let tab_layout = MobileState::mobile_tab_layout().with_context(err_context)?; + + let tab_id = self.get_new_tab_id(); + self.new_tab( + tab_id, + (vec![], vec![]), + Some("Mobile".to_string()), + Some(client_id), + ) + .with_context(err_context)?; + if let Some(tab) = self.tabs.get_mut(&tab_id) { + let mut set = HashSet::new(); + set.insert(client_id); + tab.visible_to = Some(set); + } + self.mobile_state.register_tab(client_id, tab_id); + + let is_web_client = self + .connected_clients + .borrow() + .get(&client_id) + .copied() + .unwrap_or(false); + + let client_is_gated = self.mobile_render_gate.is_gated(client_id); + if client_is_gated { + self.bus + .senders + .send_to_plugin(PluginInstruction::HoldMobileRender(client_id)) + .with_context(err_context)?; + } + + self.bus + .senders + .send_to_plugin(PluginInstruction::NewTab( + None, + None, + Some(tab_layout), + vec![], + tab_id, + None, + false, + true, + (client_id, is_web_client), + None, + )) + .with_context(err_context)?; + + Ok(()) + } + + pub fn exit_mobile_mode(&mut self, client_id: ClientId) -> Result<()> { + let err_context = || format!("failed to exit mobile mode for client {client_id}"); + + self.mobile_render_gate.ungate(client_id); + self.bus + .senders + .send_to_plugin(PluginInstruction::ReleaseMobileRender(client_id)) + .non_fatal(); + + let Some((mobile_tab_id, prior_tab_id)) = self.mobile_state.begin_exit(client_id) else { + return Ok(()); + }; + + let destination = prior_tab_id + .filter(|id| self.tabs.contains_key(id) && self.tab_visible_to(client_id, *id)) + .or_else(|| { + let mobile_tab_ids = self.mobile_state.mobile_tab_ids(); + self.tabs + .values() + .filter(|t| { + t.id != mobile_tab_id + && !mobile_tab_ids.contains(&t.id) + && match &t.visible_to { + None => true, + Some(set) => set.contains(&client_id), + } + }) + .min_by_key(|t| t.position) + .map(|t| t.id) + }); + + if let Some(dest_id) = destination { + if let Some(dest_pos) = self.get_tab_position_by_id(dest_id) { + self.switch_active_tab(dest_pos, None, true, client_id) + .with_context(err_context)?; + } + } + + if self.tabs.contains_key(&mobile_tab_id) { + self.close_tab_by_id(mobile_tab_id) + .with_context(err_context)?; + } + + self.mobile_state + .clear_shadow_focus(client_id, &mut self.tabs); + + Ok(()) + } + + pub fn toggle_mobile_mode(&mut self, client_id: ClientId) -> Result<()> { + if self.is_in_mobile_mode(client_id) { + self.exit_mobile_mode(client_id) + } else { + self.enter_mobile_mode(client_id) + } + } + + pub fn reevaluate_mobile_mode( + &mut self, + client_id: ClientId, + new_size: Size, + mobile_layout: MobileLayoutConfiguration, + threshold_cols: u16, + threshold_rows: u16, + ) -> Result<()> { + let is_web_client = self + .connected_clients + .borrow() + .get(&client_id) + .copied() + .unwrap_or(false); + let should_be_mobile = mobile_layout.should_route_to_mobile( + is_web_client, + new_size.cols, + new_size.rows, + threshold_cols, + threshold_rows, + ); + let is_mobile = self.is_in_mobile_mode(client_id); + let was_auto = self.mobile_state.was_auto_entered(client_id); + if should_be_mobile && !is_mobile { + self.enter_mobile_mode(client_id)?; + self.mobile_state.mark_auto_entered(client_id); + self.render(None)?; + } else if !should_be_mobile && is_mobile && was_auto { + self.exit_mobile_mode(client_id)?; + self.render(None)?; + } + if !should_be_mobile && self.mobile_render_gate.is_gated(client_id) { + self.mobile_render_gate.ungate(client_id); + self.render(None)?; + } + Ok(()) + } + + fn visible_tab_positions_for_client(&self, client_id: ClientId) -> Vec<usize> { + let mut positions: Vec<usize> = self + .tabs + .values() + .filter(|t| match &t.visible_to { + None => true, + Some(set) => set.contains(&client_id), + }) + .map(|t| t.position) + .collect(); + positions.sort_unstable(); + positions + } + + fn move_clients_from_closed_tab( + &mut self, + client_ids_and_mode_infos: Vec<(ClientId, ModeInfo)>, + ) -> Result<()> { + let err_context = || "failed to move clients from closed tab".to_string(); + + if self.tabs.is_empty() { + Err::<(), _>(anyhow!( + "No tabs left, cannot move clients: {:?} from closed tab", + client_ids_and_mode_infos + )) + .with_context(err_context) + .non_fatal(); + + return Ok(()); + } + let first_tab_index = *self + .tabs + .keys() + .next() + .context("screen contained no tabs") + .with_context(err_context)?; + for (client_id, client_mode_info) in client_ids_and_mode_infos { + let client_tab_history = self.tab_history.entry(client_id).or_insert_with(Vec::new); + if let Some(client_previous_tab) = client_tab_history.pop() { + if let Some(client_active_tab) = self.tabs.get_mut(&client_previous_tab) { + self.active_tab_ids.insert(client_id, client_previous_tab); + client_active_tab + .add_client(client_id, Some(client_mode_info)) + .with_context(err_context)?; + continue; + } + } + self.active_tab_ids.insert(client_id, first_tab_index); + self.tabs + .get_mut(&first_tab_index) + .with_context(err_context)? + .add_client(client_id, Some(client_mode_info)) + .with_context(err_context)?; + } + Ok(()) + } + + fn move_suppressed_panes_from_closed_tab( + &mut self, + suppressed_panes: SuppressedPanes, + ) -> Result<()> { + // TODO: this is not entirely accurate, these also sometimes contain a pane who's + // scrollback is being edited - in this case we need to close it or to move it to the + // appropriate tab + let err_context = || "Failed to move suppressed panes from closed tab"; + let first_tab_index = *self + .tabs + .keys() + .next() + .context("screen contains no tabs") + .with_context(err_context)?; + self.tabs + .get_mut(&first_tab_index) + .with_context(err_context)? + .add_suppressed_panes(suppressed_panes); + Ok(()) + } + + fn move_clients_between_tabs( + &mut self, + source_tab_index: usize, + destination_tab_index: usize, + update_mode_infos: bool, + clients_to_move: Option<Vec<ClientId>>, + ) -> Result<()> { + let err_context = || { + format!( + "failed to move clients from tab {source_tab_index} to tab {destination_tab_index}" + ) + }; + + // None ==> move all clients + let drained_clients = self + .get_indexed_tab_mut(source_tab_index) + .map(|t| t.drain_connected_clients(clients_to_move)); + + if let Some(client_mode_info_in_source_tab) = drained_clients { + let destination_tab = self + .get_indexed_tab_mut(destination_tab_index) + .context("failed to get destination tab by index") + .with_context(err_context)?; + destination_tab + .add_multiple_clients(client_mode_info_in_source_tab) + .with_context(err_context)?; + if update_mode_infos { + destination_tab + .update_input_modes() + .with_context(err_context)?; + } + destination_tab.set_force_render(); + if destination_tab.has_stack_lists() { + destination_tab.set_should_clear_display_before_rendering(); + } + destination_tab.visible(true).with_context(err_context)?; + } + Ok(()) + } + + fn update_client_tab_focus(&mut self, client_id: ClientId, new_tab_index: usize) { + match self.active_tab_ids.remove(&client_id) { + Some(old_active_index) => { + self.active_tab_ids.insert(client_id, new_tab_index); + let client_tab_history = self.tab_history.entry(client_id).or_insert_with(Vec::new); + client_tab_history.retain(|&e| e != new_tab_index); + client_tab_history.push(old_active_index); + }, + None => { + self.active_tab_ids.insert(client_id, new_tab_index); + }, + } + self.clear_bell_for_focused_pane(client_id); + } + + /// A helper function to switch to a new tab at specified position. + fn switch_active_tab( + &mut self, + new_tab_pos: usize, + should_change_pane_focus: Option<Direction>, + update_mode_infos: bool, + client_id: ClientId, + ) -> Result<()> { + let err_context = || { + format!( + "Failed to switch to active tab at position {new_tab_pos} for client id: {client_id:?}" + ) + }; + + if let Some(new_tab) = self.tabs.values().find(|t| t.position == new_tab_pos) { + match self.get_active_tab(client_id) { + Ok(current_tab) => { + // If new active tab is same as the current one, do nothing. + if current_tab.position == new_tab_pos { + return Ok(()); + } + + let current_tab_index = current_tab.id; + let new_tab_index = new_tab.id; + let affected_client_ids: Vec<ClientId> = if self.session_is_mirrored { + self.connected_clients + .borrow() + .iter() + .map(|(c, _i)| *c) + .collect() + } else { + vec![client_id] + }; + let old_focused_panes: Vec<(ClientId, Option<PaneId>)> = affected_client_ids + .iter() + .map(|c| (*c, self.get_active_pane_id(c))) + .collect(); + if self.session_is_mirrored { + self.move_clients_between_tabs( + current_tab_index, + new_tab_index, + update_mode_infos, + None, + ) + .with_context(err_context)?; + let all_connected_clients: Vec<ClientId> = self + .connected_clients + .borrow() + .iter() + .map(|(c, _i)| *c) + .collect(); + for client_id in all_connected_clients { + self.update_client_tab_focus(client_id, new_tab_index); + match ( + should_change_pane_focus, + self.get_indexed_tab_mut(new_tab_index), + ) { + (Some(direction), Some(new_tab)) => { + new_tab.focus_pane_on_edge(direction, client_id); + }, + _ => {}, + } + } + } else { + self.move_clients_between_tabs( + current_tab_index, + new_tab_index, + update_mode_infos, + Some(vec![client_id]), + ) + .with_context(err_context)?; + match ( + should_change_pane_focus, + self.get_indexed_tab_mut(new_tab_index), + ) { + (Some(direction), Some(new_tab)) => { + new_tab.focus_pane_on_edge(direction, client_id); + }, + _ => {}, + } + self.update_client_tab_focus(client_id, new_tab_index); + } + + if let Some(current_tab) = self.get_indexed_tab_mut(current_tab_index) { + if current_tab.has_no_connected_clients() { + current_tab.visible(false).with_context(err_context)?; + } + } else { + Err::<(), _>(anyhow!("Tab index {:?} not found", current_tab_index)) + .with_context(err_context) + .non_fatal(); + } + + // Clear tab bell notification for the newly active tab + if let Some(new_tab) = self.tabs.get_mut(&new_tab_index) { + let tab_id = new_tab.id; + new_tab.clear_tab_bell_notification(); + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::StopFlashTabBell(tab_id)); + } + + self.recompute_tab_size(current_tab_index) + .with_context(err_context)?; + self.recompute_tab_size(new_tab_index) + .with_context(err_context)?; + + self.log_and_report_session_state() + .with_context(err_context)?; + for (affected_client_id, old_pane_id) in old_focused_panes { + let new_pane_id = self.get_active_pane_id(&affected_client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + self.report_key_passthrough_state(affected_client_id, old, new); + } + } + return self.render(None).with_context(err_context); + }, + Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(), + } + } + Ok(()) + } + + /// A helper function to switch to a new tab with specified name. Return true if tab [name] has + /// been created, else false. + fn switch_active_tab_name(&mut self, name: String, client_id: ClientId) -> Result<bool> { + match self + .tabs + .values() + .find(|t| t.name == name && self.tab_visible_to(client_id, t.id)) + { + Some(new_tab) => { + self.switch_active_tab(new_tab.position, None, true, client_id)?; + Ok(true) + }, + None => Ok(false), + } + } + + /// Sets this [`Screen`]'s active [`Tab`] to the next tab. + pub fn switch_tab_next( + &mut self, + should_change_pane_focus: Option<Direction>, + update_mode_infos: bool, + client_id: ClientId, + ) -> Result<()> { + let err_context = || format!("failed to switch to next tab for client {client_id}"); + + let client_id = if self.get_active_tab(client_id).is_ok() { + Some(client_id) + } else { + self.get_first_client_id() + }; + + if let Some(client_id) = client_id { + match self.get_active_tab(client_id) { + Ok(active_tab) => { + let active_tab_pos = active_tab.position; + let visible = self.visible_tab_positions_for_client(client_id); + if visible.is_empty() { + return Ok(()); + } + let new_tab_pos = match visible.iter().position(|p| *p > active_tab_pos) { + Some(idx) => visible[idx], + None => visible[0], + }; + return self.switch_active_tab( + new_tab_pos, + should_change_pane_focus, + update_mode_infos, + client_id, + ); + }, + Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(), + } + } + Ok(()) + } + + /// Sets this [`Screen`]'s active [`Tab`] to the previous tab. + pub fn switch_tab_prev( + &mut self, + should_change_pane_focus: Option<Direction>, + update_mode_infos: bool, + client_id: ClientId, + ) -> Result<()> { + let err_context = || format!("failed to switch to previous tab for client {client_id}"); + + let client_id = if self.get_active_tab(client_id).is_ok() { + Some(client_id) + } else { + self.get_first_client_id() + }; + + if let Some(client_id) = client_id { + match self.get_active_tab(client_id) { + Ok(active_tab) => { + let active_tab_pos = active_tab.position; + let visible = self.visible_tab_positions_for_client(client_id); + if visible.is_empty() { + return Ok(()); + } + let new_tab_pos = match visible.iter().rev().find(|p| **p < active_tab_pos) { + Some(p) => *p, + None => *visible.last().unwrap(), + }; + + return self.switch_active_tab( + new_tab_pos, + should_change_pane_focus, + update_mode_infos, + client_id, + ); + }, + Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(), + } + } + Ok(()) + } + + pub fn go_to_tab(&mut self, tab_index: usize, client_id: ClientId) -> Result<()> { + let target_pos = tab_index.saturating_sub(1); + if let Some(target_id) = self.get_tab_id_at_position(target_pos) { + if !self.tab_visible_to(client_id, target_id) { + return Ok(()); + } + } + self.switch_active_tab(target_pos, None, true, client_id) + } + + pub fn go_to_tab_name(&mut self, name: String, client_id: ClientId) -> Result<bool> { + self.switch_active_tab_name(name, client_id) + } + + fn close_tab_by_id(&mut self, tab_id: usize) -> Result<()> { + let err_context = || format!("failed to close tab at index {tab_id:?}"); + + let mut tab_to_close = self.tabs.remove(&tab_id).with_context(err_context)?; + self.mobile_state.remove_fit_for_tab(tab_id); + let mut pane_ids = tab_to_close.get_all_pane_ids(); + + // here we extract the suppressed panes (these are background panes that don't care which + // tab they are in, and in the future we should probably make them global to screen rather + // than to each tab) and move them to another tab if there is one + let suppressed_panes = tab_to_close.extract_suppressed_panes(); + for suppressed_pane_id in suppressed_panes.keys() { + pane_ids.retain(|p| p != suppressed_pane_id); + } + + let _ = self.bus.senders.send_to_plugin(PluginInstruction::Update( + pane_ids + .iter() + .copied() + .map(|p_id| (None, None, Event::PaneClosed(p_id.into()))) + .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 + self.bus + .senders + .send_to_pty(PtyInstruction::CloseTab(pane_ids)) + .with_context(err_context)?; + if self.tabs.is_empty() { + self.active_tab_ids.clear(); + self.bus + .senders + .send_to_server(ServerInstruction::Render(None)) + .with_context(err_context) + } else { + let client_mode_infos_in_closed_tab = tab_to_close.drain_connected_clients(None); + self.move_clients_from_closed_tab(client_mode_infos_in_closed_tab) + .with_context(err_context)?; + self.move_suppressed_panes_from_closed_tab(suppressed_panes) + .with_context(err_context)?; + let visible_tab_indices: HashSet<usize> = + self.active_tab_ids.values().copied().collect(); + for t in self.tabs.values_mut() { + if visible_tab_indices.contains(&t.id) { + t.set_force_render(); + t.visible(true).with_context(err_context)?; + } + if t.position > tab_to_close.position { + t.position -= 1; + } + } + self.log_and_report_session_state() + .with_context(err_context)?; + self.render(None).with_context(err_context) + } + } + + // Closes the client_id's focused tab + pub fn close_tab(&mut self, client_id: ClientId) -> Result<()> { + let err_context = || format!("failed to close tab for client {client_id:?}"); + + let client_id = if self.get_active_tab(client_id).is_ok() { + Some(client_id) + } else { + self.get_first_client_id() + }; + + match client_id { + Some(client_id) => { + let active_tab_index = *self + .active_tab_ids + .get(&client_id) + .with_context(err_context)?; + self.close_tab_by_id(active_tab_index) + .with_context(err_context) + }, + None => Ok(()), + } + } + + pub fn resize_to_screen(&mut self, new_screen_size: Size) -> Result<()> { + let err_context = || format!("failed to resize to screen size: {new_screen_size:#?}"); + + if self.size != new_screen_size { + self.size = new_screen_size; + for tab in self.tabs.values_mut() { + tab.resize_whole_tab(new_screen_size) + .with_context(err_context)?; + tab.set_force_render(); + } + self.log_and_report_session_state() + .with_context(err_context)?; + self.render(None).with_context(err_context) + } else { + Ok(()) + } + } + + pub fn set_client_size(&mut self, client_id: ClientId, size: Size) { + self.client_sizes.insert(client_id, size); + } + + pub fn recompute_tab_size(&mut self, tab_id: usize) -> Result<()> { + let err_context = || format!("failed to recompute size for tab {tab_id}"); + + if self.mobile_state.has_fit(tab_id) { + for _ in 0..FIT_RESIZE_MAX_ITERS { + let Some(new_size) = self.compute_fit_size(tab_id) else { + break; + }; + let Some(tab) = self.tabs.get_mut(&tab_id) else { + break; + }; + if tab.size == new_size { + break; + } + tab.resize_whole_tab(new_size).with_context(err_context)?; + } + if let Some(tab) = self.tabs.get_mut(&tab_id) { + tab.set_should_clear_display_before_rendering(); + tab.set_force_render(); + } + return Ok(()); + } + + let mut rows: Vec<usize> = Vec::new(); + let mut cols: Vec<usize> = Vec::new(); + for (client_id, active_tab) in self.active_tab_ids.iter() { + if *active_tab == tab_id { + if let Some(size) = self.client_sizes.get(client_id) { + rows.push(size.rows); + cols.push(size.cols); + } + } + } + if rows.is_empty() || cols.is_empty() { + return Ok(()); + } + rows.sort_unstable(); + cols.sort_unstable(); + let new_size = Size { + rows: rows[0], + cols: cols[0], + }; + if let Some(tab) = self.tabs.get_mut(&tab_id) { + if tab.size != new_size { + tab.resize_whole_tab(new_size).with_context(err_context)?; + tab.set_force_render(); + } + } + Ok(()) + } + + pub(crate) fn compute_fit_size(&self, tab_id: usize) -> Option<Size> { + self.mobile_state.compute_fit_size(tab_id, &self.tabs) + } + + pub fn set_tab_fit( + &mut self, + client_id: ClientId, + tab_id: usize, + pane_id: PaneId, + size: Size, + ) -> Result<()> { + self.mobile_state + .set_fit(client_id, tab_id, pane_id, size, &mut self.tabs); + self.recompute_tab_size(tab_id) + } + + pub fn exit_fit_mode(&mut self, client_id: ClientId) -> Result<bool> { + match self + .mobile_state + .clear_fit_owned_by(client_id, &mut self.tabs) + { + Some(tab_id) => { + self.recompute_tab_size(tab_id)?; + Ok(true) + }, + None => Ok(false), + } + } + + pub(crate) fn clear_fit_for_closed_pane(&mut self, pane_id: PaneId) -> Result<bool> { + match self.mobile_state.clear_fit_for_pane(pane_id) { + Some(tab_id) => { + self.recompute_tab_size(tab_id)?; + Ok(true) + }, + None => Ok(false), + } + } + + pub fn update_pixel_dimensions(&mut self, pixel_dimensions: PixelDimensions) { + self.pixel_dimensions.merge(pixel_dimensions); + if let Some(character_cell_size) = self.pixel_dimensions.character_cell_size { + *self.character_cell_size.borrow_mut() = Some(character_cell_size); + } else if let Some(text_area_size) = self.pixel_dimensions.text_area_size { + let character_cell_size_height = text_area_size.height / self.size.rows; + let character_cell_size_width = text_area_size.width / self.size.cols; + let character_cell_size = SizeInPixels { + height: character_cell_size_height, + width: character_cell_size_width, + }; + *self.character_cell_size.borrow_mut() = Some(character_cell_size); + } + } + + pub fn update_kitty_graphics_support(&mut self, client_id: ClientId, supported: bool) { + let supported = supported && self.support_kitty_graphics_protocol; + self.kitty_host_capabilities + .borrow_mut() + .insert(client_id, supported); + self.push_kitty_host_support_to_tabs(); + } + + fn kitty_host_support_aggregate(&self) -> Option<KittyHostSupport> { + if !self.support_kitty_graphics_protocol { + return Some(KittyHostSupport::ProtocolDisabled); + } + let capabilities = self.kitty_host_capabilities.borrow(); + if capabilities.is_empty() { + None + } else { + Some(KittyHostSupport::from_host_capability( + capabilities.values().any(|supported| *supported), + )) + } + } + + fn push_kitty_host_support_to_tabs(&mut self) { + if let Some(aggregate) = self.kitty_host_support_aggregate() { + for tab in self.tabs.values_mut() { + tab.update_kitty_host_support(aggregate); + } + } + } + + pub fn update_terminal_background_color(&mut self, background_color_instruction: String) { + if let Some(AnsiCode::RgbCode((r, g, b))) = + xparse_color(background_color_instruction.as_bytes()) + { + let bg_palette_color = PaletteColor::Rgb((r, g, b)); + self.terminal_emulator_colors.borrow_mut().bg = bg_palette_color; + } + } + + pub fn update_terminal_foreground_color(&mut self, foreground_color_instruction: String) { + if let Some(AnsiCode::RgbCode((r, g, b))) = + xparse_color(foreground_color_instruction.as_bytes()) + { + let fg_palette_color = PaletteColor::Rgb((r, g, b)); + self.terminal_emulator_colors.borrow_mut().fg = fg_palette_color; + } + } + + pub fn update_terminal_color_registers(&mut self, color_registers: Vec<(usize, String)>) { + let mut terminal_emulator_color_codes = self.terminal_emulator_color_codes.borrow_mut(); + for (color_register, color_sequence) in color_registers { + terminal_emulator_color_codes.insert(color_register, color_sequence); + } + } + + /// Enqueue a whitelisted host-terminal query from pane `pane_id`. + /// Queries are serialized globally: at most one is in flight to the + /// client at a time; the rest wait in `forward_queue`. Returns the + /// allocated token so callers (tests) can observe it if useful. + pub fn forward_host_query( + &mut self, + pane_id: PaneId, + query: crate::host_query::HostQuery, + ) -> u32 { + // ColorPaletteMode is answered locally — Zellij already knows + // the host's mode from its own startup query and from + // unsolicited DSR 997 updates. Skip the entire forwarding + // machinery (no token, no in-flight slot, no host round-trip) + // and write the reply straight to the originating pane's pty. + if let crate::host_query::HostQuery::ColorPaletteMode = query { + self.answer_color_palette_mode_query_locally(pane_id); + return STARTUP_SENTINEL_TOKEN; // sentinel: no real forward happened + } + let token = self.next_forward_token; + // Skip over the reserved sentinel (0) on wrap; allocate a fresh + // u32 for every forward. + self.next_forward_token = self.next_forward_token.wrapping_add(1); + if self.next_forward_token == STARTUP_SENTINEL_TOKEN { + self.next_forward_token = 1; + } + if self.forward_in_flight_token.is_some() { + self.forward_queue.push_back(PendingForward { + token, + pane_id, + query, + }); + } else { + self.dispatch_forward(token, pane_id, query); + } + token + } + + fn answer_color_palette_mode_query_locally(&mut self, pane_id: PaneId) { + if matches!(pane_id, PaneId::Plugin(_)) { + return; + } + let code: u8 = match self.effective_host_terminal_theme_mode() { + HostTerminalThemeMode::Dark => 1, + HostTerminalThemeMode::Light => 2, + }; + let reply = format!("\u{1b}[?997;{}n", code).into_bytes(); + // Route via Tab so the reply lands on the pane in the correct + // stream position and any PTY input the app emitted while + // waiting is replayed. + let _ = self.resume_pane_after_forward(pane_id, reply); + } + + fn effective_host_terminal_theme_mode(&self) -> HostTerminalThemeMode { + self.host_terminal_theme_mode.unwrap_or_else(|| { + match detect_theme_hue(self.style.colors.text_unselected.background) { + ThemeHue::Dark => HostTerminalThemeMode::Dark, + ThemeHue::Light => HostTerminalThemeMode::Light, + } + }) + } + + /// Dispatch a forward to the client and mark the slot as in-flight. + /// Spawns a one-shot timeout task on the global tokio runtime that + /// synthesizes an empty reply for `token` after + /// `SERVER_FORWARD_TIMEOUT_MS`. The handler's token-equality guard + /// makes the timeout a no-op if a real reply arrived first, so no + /// explicit cancellation is needed. + fn dispatch_forward( + &mut self, + token: u32, + pane_id: PaneId, + query: crate::host_query::HostQuery, + ) { + let query_bytes = query.to_query_bytes(); + self.pending_forwarded_queries + .insert(token, PendingForwardEntry { pane_id, query }); + self.forward_in_flight_token = Some(token); + let _ = self + .bus + .senders + .send_to_server(ServerInstruction::ForwardQueryToHost(token, query_bytes)); + let senders = self.bus.senders.clone(); + crate::global_async_runtime::get_tokio_runtime().spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(SERVER_FORWARD_TIMEOUT_MS)).await; + let _ = senders.send_to_screen(ScreenInstruction::ForwardedReplyFromHost { + token, + reply_bytes: Vec::new(), + }); + }); + } + + /// Handle a host-reply observed by the client for token `token`. + /// Writes the bytes to the originating pane's pty (if still present) + /// and releases the in-flight slot so the next queued forward can + /// dispatch. + /// + /// An empty `reply_bytes` is treated as "nobody answered" — either + /// because no client was attached to forward the query, or the + /// chosen client's host timed out. In that case we try to answer + /// from Zellij's cached view of the host (pixel dims, bg/fg, + /// palette) so the pane still gets a well-formed reply instead of + /// zero bytes. If the cache has nothing relevant either, the pane + /// receives an empty write and the app decides what to do. + pub fn handle_forwarded_reply_from_host( + &mut self, + token: u32, + reply_bytes: Vec<u8>, + ) -> Result<()> { + // Stale-reply guard. Both the real `ForwardedReplyFromHost` + // path and the server-side timeout path land here. If a real + // reply landed first (releasing the slot AND dispatching the + // next queued forward), the late timeout's empty reply for the + // already-cleared token must be a no-op — releasing the slot + // again would clobber the new in-flight forward. The same + // logic also rejects duplicate replies and replies for tokens + // belonging to a previously-closed pane. + if self.forward_in_flight_token != Some(token) { + log::debug!( + "Dropping stale forwarded reply (token={}, in-flight={:?}, len={} bytes)", + token, + self.forward_in_flight_token, + reply_bytes.len(), + ); + return Ok(()); + } + if let Some(entry) = self.pending_forwarded_queries.remove(&token) { + let PendingForwardEntry { pane_id, query } = entry; + match pane_id { + PaneId::Terminal(_) => { + let payload = if reply_bytes.is_empty() { + self.synthesize_cached_reply(&query) + } else { + reply_bytes + }; + // Route via Tab so the reply lands on the pane in + // the correct stream position and any PTY input + // the app emitted while waiting is replayed. + self.resume_pane_after_forward(pane_id, payload)?; + }, + PaneId::Plugin(_) => { + // Plugin panes do not issue whitelisted host queries; + // if we reach here the mapping was populated + // erroneously. Drop the reply. + log::warn!( + "Discarding host reply for plugin pane (token={}); plugins do not forward CSI/OSC queries", + token + ); + }, + } + } + // Release the slot and dispatch the next queued forward, if any. + self.forward_in_flight_token = None; + if let Some(next) = self.forward_queue.pop_front() { + self.dispatch_forward(next.token, next.pane_id, next.query); + } + Ok(()) + } + + /// Deliver a forwarded reply (or cache-fallback synthesis, or a + /// locally-answered query payload) to the originating pane via + /// the owning Tab. The Tab handler writes the bytes to PTY and + /// then re-feeds any PTY input that was buffered while the pane + /// was forward-paused, preserving query/reply ordering. + pub fn resume_pane_after_forward( + &mut self, + pane_id: PaneId, + reply_bytes: Vec<u8>, + ) -> Result<()> { + let terminal_id = match pane_id { + PaneId::Terminal(id) => id, + PaneId::Plugin(_) => { + // Plugin panes do not forward queries — dropping is + // the correct behaviour matching the existing + // forwarding path's plugin-pane guard. + return Ok(()); + }, + }; + let mut found = false; + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.resume_pane_after_forward(terminal_id, reply_bytes.clone()) + .non_fatal(); + found = true; + break; + } + } + if !found { + // No tab owns the pane — the pane was closed between the + // forward dispatch and the reply arrival. The terminal id + // is probably defunct; the write goes to the PTY writer + // anyway because an empty payload is the canonical "host + // declined" reply some apps rely on, and a non-empty + // payload may still be deliverable until the pty drains. + let _ = self + .bus + .senders + .send_to_pty_writer(PtyWriteInstruction::Write(reply_bytes, terminal_id, None)); + log::debug!( + "ResumePaneAfterForward: pane {:?} not in any tab, fell back to direct PTY write", + pane_id + ); + } + Ok(()) + } + + pub fn handle_nested_session_message_from_pane( + &mut self, + pane_id: PaneId, + message: NestedSessionMessage, + ) { + match message { + NestedSessionMessage::Announce { session_name, .. } => { + self.handle_nested_guest_announce(pane_id, session_name); + }, + NestedSessionMessage::Pong => { + self.nested_guest_tracker.on_pong(pane_id, Instant::now()); + log::debug!("nested session guest in pane {:?} ponged", pane_id); + }, + NestedSessionMessage::Bye => { + log::info!( + "nested session guest in pane {:?} sent bye, clearing guest flag", + pane_id + ); + self.clear_nested_guest(pane_id); + }, + NestedSessionMessage::FocusHost { direction } => { + let clients_focused_on_pane = self.clients_with_pane_focused(pane_id); + match direction { + None => { + self.unset_nested_fullscreen_if_active(pane_id); + for client_id in clients_focused_on_pane { + self.nested_guest_choices + .insert((client_id, pane_id), NestedGuestChoice::Dismissed); + self.sync_guest_choice_indicator(client_id, pane_id); + self.set_client_dimmed(client_id, false, None); + let _ = self.bus.senders.send_to_server( + ServerInstruction::KeyPassthroughChanged( + client_id, pane_id, pane_id, false, None, true, + ), + ); + } + let _ = self.render(None); + }, + Some(direction) => { + for client_id in clients_focused_on_pane { + let new_pane_id = self + .tabs + .values_mut() + .find(|tab| tab.has_pane_with_pid(&pane_id)) + .and_then(|tab| { + tab.focus_pane_adjacent_to(pane_id, direction, client_id) + }); + match new_pane_id { + Some(new_pane_id) => { + self.report_key_passthrough_state_with_direction( + client_id, + pane_id, + new_pane_id, + Some(direction), + ); + let _ = self.render(None); + let _ = self.log_and_report_session_state(); + }, + None => { + self.bubble_focus_to_host(client_id, direction); + }, + } + } + }, + } + }, + NestedSessionMessage::ShortcutUpdate { ascend_keys, .. } => { + if !self.nested_guest_tracker.is_tracked(pane_id) { + log::debug!( + "ignoring nested shortcut update from non-live guest pane {:?}", + pane_id + ); + return; + } + self.guest_ascend_keys.insert(pane_id, ascend_keys); + self.refresh_nested_ascend_keys_for_pane(pane_id); + }, + NestedSessionMessage::ToggleHostFullscreen { fullscreen } => { + if !self.nested_guest_tracker.is_tracked(pane_id) { + log::debug!( + "ignoring nested fullscreen request from non-live guest pane {:?}", + pane_id + ); + return; + } + self.apply_nested_guest_fullscreen(pane_id, fullscreen); + if let Some(client_id) = self.nested_via_client_id { + let payload = nested_session::encode_payload( + &NestedSessionMessage::ToggleHostFullscreen { fullscreen }, + ); + let _ = self.bus.senders.send_to_server( + ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload), + ); + } + let _ = self.render(None); + let _ = self.log_and_report_session_state(); + }, + other => { + log::debug!( + "dropping unsupported nested session message from pane {:?}: {:?}", + pane_id, + other + ); + }, + } + } + + fn handle_nested_guest_announce(&mut self, pane_id: PaneId, guest_session_name: String) { + use crate::nested_guest::AnnounceKind; + let terminal_id = match pane_id { + PaneId::Terminal(terminal_id) => terminal_id, + PaneId::Plugin(_) => return, + }; + let announce_kind = self + .nested_guest_tracker + .on_announce(pane_id, Instant::now()); + let is_fresh = announce_kind == AnnounceKind::New; + if is_fresh { + self.nested_guest_choices + .retain(|(_, choice_pane_id), _| *choice_pane_id != pane_id); + } + let connected_client_ids: Vec<ClientId> = + self.connected_clients.borrow().keys().copied().collect(); + let guest_modal_shortcuts = self.guest_modal_shortcuts(); + let owning_tab = self + .tabs + .values_mut() + .find(|tab| tab.has_pane_with_pid(&pane_id)); + match owning_tab { + Some(tab) => { + tab.set_pane_is_nested_guest(pane_id, true); + tab.set_guest_session_name_on_pane(pane_id, Some(guest_session_name.clone())); + tab.set_guest_modal_shortcuts_on_pane(pane_id, guest_modal_shortcuts); + if is_fresh { + tab.clear_all_guest_choice_indicators_on_pane(pane_id); + if self.nested_session_handling == NestedSessionHandling::Ask { + tab.set_guest_modal_on_pane(pane_id, &connected_client_ids); + } + } + }, + None => { + self.nested_guest_tracker.remove(pane_id); + return; + }, + } + let mut ancestry = self.nested_ancestry.clone(); + ancestry.push(self.session_name.clone()); + let announce_ack = NestedSessionMessage::AnnounceAck { + ancestry, + capabilities: vec![NestedSessionCapability::NestedControl], + descend_keys: self.own_descend_shortcut(), + }; + let _ = self + .bus + .senders + .send_to_pty_writer(PtyWriteInstruction::Write( + nested_session::encode_frame(&announce_ack), + terminal_id, + None, + )); + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::StartNestedGuestPing(pane_id)); + if is_fresh { + self.auto_apply_nested_session_handling(pane_id, &connected_client_ids); + } else { + self.revive_nested_guest(pane_id); + } + let _ = self.render(None); + log::info!( + "nested session handshake ({:?}): guest session {:?} announced in pane {:?}, sent announce_ack", + announce_kind, + guest_session_name, + pane_id + ); + } + + fn suspend_nested_guest(&mut self, pane_id: PaneId) { + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::StopNestedGuestPing(pane_id)); + self.unset_nested_fullscreen_if_active(pane_id); + self.reported_guest_fullscreen.remove(&pane_id); + let affected_clients: Vec<ClientId> = self + .clients_with_pane_focused(pane_id) + .into_iter() + .chain(self.dimmed_clients.iter().copied()) + .collect(); + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.clear_all_guest_modals_on_pane(pane_id); + break; + } + } + for client_id in affected_clients { + self.set_client_dimmed(client_id, false, None); + let _ = self + .bus + .senders + .send_to_server(ServerInstruction::KeyPassthroughChanged( + client_id, pane_id, pane_id, false, None, false, + )); + } + } + + fn revive_nested_guest(&mut self, pane_id: PaneId) { + self.broadcast_ancestry_update_to_guests(); + self.sync_nested_guest_fullscreen_state(); + let client_ids: Vec<ClientId> = self.connected_clients.borrow().keys().copied().collect(); + for client_id in client_ids { + self.sync_guest_choice_indicator(client_id, pane_id); + if self.get_active_pane_id(&client_id) == Some(pane_id) { + self.report_key_passthrough_state(client_id, pane_id, pane_id); + } + } + } + + fn handle_guest_modal_choice( + &mut self, + client_id: ClientId, + pane_id: PaneId, + outcome: GuestModalOutcome, + ) { + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.clear_guest_modal_on_pane(pane_id, client_id); + break; + } + } + match outcome { + GuestModalOutcome::Descend => { + self.nested_guest_choices + .insert((client_id, pane_id), NestedGuestChoice::Descend); + self.sync_guest_choice_indicator(client_id, pane_id); + if self.get_active_pane_id(&client_id) == Some(pane_id) { + self.report_key_passthrough_state(client_id, pane_id, pane_id); + } + let _ = self.render(None); + let _ = self.log_and_report_session_state(); + }, + GuestModalOutcome::Zoom => { + self.nested_guest_choices + .insert((client_id, pane_id), NestedGuestChoice::Zoom); + self.sync_guest_choice_indicator(client_id, pane_id); + if self.get_active_pane_id(&client_id) == Some(pane_id) { + self.report_key_passthrough_state(client_id, pane_id, pane_id); + } + self.apply_nested_guest_fullscreen(pane_id, true); + self.sync_nested_guest_fullscreen_state(); + if let Some(via_client_id) = self.nested_via_client_id { + let payload = nested_session::encode_payload( + &NestedSessionMessage::ToggleHostFullscreen { fullscreen: true }, + ); + let _ = self.bus.senders.send_to_server( + ServerInstruction::EmitNestedSessionFrameToClient(via_client_id, payload), + ); + } + let _ = self.render(None); + let _ = self.log_and_report_session_state(); + }, + } + } + + fn record_nested_guest_choice_for_client( + &mut self, + client_id: ClientId, + pane_id: PaneId, + choice: NestedGuestChoice, + ) { + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.clear_guest_modal_on_pane(pane_id, client_id); + break; + } + } + self.nested_guest_choices + .insert((client_id, pane_id), choice); + self.sync_guest_choice_indicator(client_id, pane_id); + if self.get_active_pane_id(&client_id) == Some(pane_id) { + self.report_key_passthrough_state(client_id, pane_id, pane_id); + } + } + + fn auto_apply_nested_session_handling(&mut self, pane_id: PaneId, client_ids: &[ClientId]) { + match self.nested_session_handling { + NestedSessionHandling::Descend => { + for client_id in client_ids { + self.record_nested_guest_choice_for_client( + *client_id, + pane_id, + NestedGuestChoice::Descend, + ); + } + }, + NestedSessionHandling::Fullscreen => { + for client_id in client_ids { + self.record_nested_guest_choice_for_client( + *client_id, + pane_id, + NestedGuestChoice::Zoom, + ); + } + self.apply_nested_guest_fullscreen(pane_id, true); + self.sync_nested_guest_fullscreen_state(); + if let Some(via_client_id) = self.nested_via_client_id { + let payload = nested_session::encode_payload( + &NestedSessionMessage::ToggleHostFullscreen { fullscreen: true }, + ); + let _ = self.bus.senders.send_to_server( + ServerInstruction::EmitNestedSessionFrameToClient(via_client_id, payload), + ); + } + }, + NestedSessionHandling::Ask | NestedSessionHandling::Never => {}, + } + } + + fn focus_guest_session(&mut self, client_id: ClientId) { + let focused_pane_id = match self.get_active_pane_id(&client_id) { + Some(pane_id) => pane_id, + None => return, + }; + let is_live_guest = self.tabs.values().any(|tab| { + tab.has_pane_with_pid(&focused_pane_id) && tab.is_pane_nested_guest(focused_pane_id) + }) && self.nested_guest_tracker.is_tracked(focused_pane_id); + if !is_live_guest { + return; + } + self.nested_guest_choices + .insert((client_id, focused_pane_id), NestedGuestChoice::Descend); + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&focused_pane_id) { + tab.clear_guest_modal_on_pane(focused_pane_id, client_id); + break; + } + } + self.sync_guest_choice_indicator(client_id, focused_pane_id); + self.report_key_passthrough_state(client_id, focused_pane_id, focused_pane_id); + let _ = self.render(None); + let _ = self.log_and_report_session_state(); + } + + pub fn clear_nested_guest(&mut self, pane_id: PaneId) { + self.nested_guest_tracker.remove(pane_id); + self.guest_ascend_keys.remove(&pane_id); + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::StopNestedGuestPing(pane_id)); + self.unset_nested_fullscreen_if_active(pane_id); + self.reported_guest_fullscreen.remove(&pane_id); + let mut affected_clients: Vec<ClientId> = self + .nested_guest_choices + .iter() + .filter(|((_, choice_pane_id), choice)| { + *choice_pane_id == pane_id && choice.enters_passthrough() + }) + .map(|((choice_client_id, _), _)| *choice_client_id) + .collect(); + self.nested_guest_choices + .retain(|(_, choice_pane_id), _| *choice_pane_id != pane_id); + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.set_pane_is_nested_guest(pane_id, false); + tab.clear_all_guest_modals_on_pane(pane_id); + tab.clear_all_guest_choice_indicators_on_pane(pane_id); + break; + } + } + + for client_id in self.clients_with_pane_focused(pane_id) { + if !affected_clients.contains(&client_id) { + affected_clients.push(client_id); + } + } + for client_id in affected_clients { + self.set_client_dimmed(client_id, false, None); + let _ = self + .bus + .senders + .send_to_server(ServerInstruction::KeyPassthroughChanged( + client_id, pane_id, pane_id, false, None, false, + )); + } + } + + fn show_guest_modals_for_new_client(&mut self, client_id: ClientId) { + let mut live_guest_panes = vec![]; + for tab in self.tabs.values() { + for pane_id in tab.nested_guest_pane_ids() { + if self.nested_guest_tracker.is_tracked(pane_id) { + live_guest_panes.push(pane_id); + } + } + } + for pane_id in live_guest_panes { + if self + .nested_guest_choices + .contains_key(&(client_id, pane_id)) + { + continue; + } + match self.nested_session_handling { + NestedSessionHandling::Ask => { + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.set_guest_modal_on_pane(pane_id, &[client_id]); + break; + } + } + }, + NestedSessionHandling::Descend => { + self.record_nested_guest_choice_for_client( + client_id, + pane_id, + NestedGuestChoice::Descend, + ); + }, + NestedSessionHandling::Fullscreen => { + self.record_nested_guest_choice_for_client( + client_id, + pane_id, + NestedGuestChoice::Zoom, + ); + }, + NestedSessionHandling::Never => {}, + } + } + } + + fn should_route_keys_to_pane(&self, client_id: ClientId, pane_id: PaneId) -> bool { + for tab in self.tabs.values() { + if tab.has_pane_with_pid(&pane_id) { + let is_guest = tab.is_pane_nested_guest(pane_id); + let is_tracked = self.nested_guest_tracker.is_tracked(pane_id); + let has_descended = self + .nested_guest_choices + .get(&(client_id, pane_id)) + .map(|choice| choice.enters_passthrough()) + .unwrap_or(false); + return is_guest && is_tracked && has_descended; + } + } + false + } + + fn downgrade_zoom_choice_to_descend(&mut self, pane_id: PaneId) { + let mut affected_clients = vec![]; + for ((client_id, choice_pane_id), choice) in self.nested_guest_choices.iter_mut() { + if *choice_pane_id == pane_id && *choice == NestedGuestChoice::Zoom { + *choice = NestedGuestChoice::Descend; + affected_clients.push(*client_id); + } + } + for client_id in affected_clients { + self.sync_guest_choice_indicator(client_id, pane_id); + } + } + + fn guest_choice_indicator_for( + &self, + client_id: ClientId, + pane_id: PaneId, + ) -> Option<GuestChoiceIndicator> { + match self.nested_guest_choices.get(&(client_id, pane_id)) { + Some(NestedGuestChoice::Descend) => Some(GuestChoiceIndicator::Descended), + Some(NestedGuestChoice::Dismissed) => Some(GuestChoiceIndicator::Dismissed), + Some(NestedGuestChoice::Zoom) | None => None, + } + } + + fn sync_guest_choice_indicator(&mut self, client_id: ClientId, pane_id: PaneId) { + let indicator = self.guest_choice_indicator_for(client_id, pane_id); + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.set_guest_choice_indicator_on_pane(pane_id, client_id, indicator); + break; + } + } + } + + fn report_key_passthrough_state( + &mut self, + client_id: ClientId, + old_pane_id: PaneId, + new_pane_id: PaneId, + ) { + self.report_key_passthrough_state_with_direction(client_id, old_pane_id, new_pane_id, None); + } + + fn report_key_passthrough_state_with_direction( + &mut self, + client_id: ClientId, + old_pane_id: PaneId, + new_pane_id: PaneId, + entered_from_direction: Option<Direction>, + ) { + let should_route = self.should_route_keys_to_pane(client_id, new_pane_id); + let guest_pane_id = if should_route { + Some(new_pane_id) + } else { + None + }; + self.set_client_dimmed(client_id, should_route, guest_pane_id); + let _ = self + .bus + .senders + .send_to_server(ServerInstruction::KeyPassthroughChanged( + client_id, + old_pane_id, + new_pane_id, + should_route, + entered_from_direction, + true, + )); + } + + fn set_client_dimmed( + &mut self, + client_id: ClientId, + dimmed: bool, + guest_pane_id: Option<PaneId>, + ) { + let changed = if dimmed { + self.dimmed_clients.insert(client_id) + } else { + self.dimmed_clients.remove(&client_id) + }; + let ascend_keys = if dimmed { + guest_pane_id + .and_then(|pane_id| self.guest_ascend_keys.get(&pane_id).cloned()) + .unwrap_or_default() + } else { + vec![] + }; + let ascend_keys_changed = self + .mode_info + .get(&client_id) + .map(|mode_info| mode_info.nested_ascend_keys != ascend_keys) + .unwrap_or_else(|| !ascend_keys.is_empty()); + if !changed && !ascend_keys_changed { + return; + } + let mode_info = self + .mode_info + .entry(client_id) + .or_insert_with(|| self.default_mode_info.clone()); + mode_info.session_dimmed = if dimmed { Some(true) } else { None }; + mode_info.nested_ascend_keys = ascend_keys; + let mode_info = mode_info.clone(); + for tab in self.tabs.values_mut() { + tab.change_mode_info(mode_info.clone(), client_id); + tab.mark_active_pane_for_rerender(client_id); + tab.set_client_dimmed(client_id, dimmed); + } + for tab in self.tabs.values_mut() { + let _ = tab.update_input_modes(); + } + let _ = self.render(None); + } + + fn base_input_mode(&self) -> InputMode { + self.default_mode_info.base_mode.unwrap_or_default() + } + + fn own_ascend_shortcut(&self) -> Vec<KeyWithModifier> { + shortcut_for_action( + &self.default_mode_info.keybinds, + self.base_input_mode(), + |action| matches!(action, Action::FocusHostSession), + ) + .unwrap_or_default() + } + + fn own_descend_shortcut(&self) -> Vec<KeyWithModifier> { + shortcut_for_action( + &self.default_mode_info.keybinds, + self.base_input_mode(), + |action| matches!(action, Action::FocusGuestSession), + ) + .unwrap_or_default() + } + + fn own_host_zoom_shortcut(&self) -> Vec<KeyWithModifier> { + shortcut_for_action( + &self.default_mode_info.keybinds, + self.base_input_mode(), + |action| matches!(action, Action::ToggleHostFullscreen), + ) + .unwrap_or_default() + } + + fn guest_modal_shortcuts(&self) -> GuestModalShortcuts { + GuestModalShortcuts { + zoom: format_guest_modal_shortcut(&self.own_host_zoom_shortcut()), + ascend: format_guest_modal_shortcut(&self.own_ascend_shortcut()), + descend: format_guest_modal_shortcut(&self.own_descend_shortcut()), + } + } + + fn refresh_nested_ascend_keys_for_pane(&mut self, pane_id: PaneId) { + let keys = self + .guest_ascend_keys + .get(&pane_id) + .cloned() + .unwrap_or_default(); + let dimmed_client_ids: Vec<ClientId> = self.dimmed_clients.iter().copied().collect(); + let mut clients_to_update: Vec<(ClientId, ModeInfo)> = vec![]; + for client_id in dimmed_client_ids { + if self.get_active_pane_id(&client_id) != Some(pane_id) { + continue; + } + if let Some(mode_info) = self.mode_info.get_mut(&client_id) { + if mode_info.nested_ascend_keys != keys { + mode_info.nested_ascend_keys = keys.clone(); + clients_to_update.push((client_id, mode_info.clone())); + } + } + } + if clients_to_update.is_empty() { + return; + } + for (client_id, mode_info) in clients_to_update { + for tab in self.tabs.values_mut() { + tab.change_mode_info(mode_info.clone(), client_id); + } + } + for tab in self.tabs.values_mut() { + let _ = tab.update_input_modes(); + } + let _ = self.render(None); + } + + fn set_host_descended(&mut self, host_descended: bool) { + if self.host_descended != host_descended { + self.host_descended = host_descended; + self.update_all_clients_nesting_mode_info(); + } + } + + fn broadcast_nested_shortcuts(&mut self) { + if let Some(client_id) = self.nested_via_client_id { + let payload = nested_session::encode_payload(&NestedSessionMessage::ShortcutUpdate { + ascend_keys: self.own_ascend_shortcut(), + descend_keys: vec![], + }); + let _ = + self.bus + .senders + .send_to_server(ServerInstruction::EmitNestedSessionFrameToClient( + client_id, payload, + )); + } + let frame = nested_session::encode_frame(&NestedSessionMessage::ShortcutUpdate { + ascend_keys: vec![], + descend_keys: self.own_descend_shortcut(), + }); + for terminal_id in self.nested_guest_terminal_ids() { + let _ = self + .bus + .senders + .send_to_pty_writer(PtyWriteInstruction::Write(frame.clone(), terminal_id, None)); + } + } + + fn update_all_clients_nesting_mode_info(&mut self) { + let ancestry = self.nested_ancestry.clone(); + let host_fullscreen = if self.host_fullscreen { + Some(true) + } else { + None + }; + let session_ascended = if !ancestry.is_empty() && !self.host_descended { + Some(true) + } else { + None + }; + let descend_keys = self.host_descend_keys.clone(); + self.default_mode_info.session_ancestry = ancestry.clone(); + self.default_mode_info.host_fullscreen = host_fullscreen; + self.default_mode_info.session_ascended = session_ascended; + self.default_mode_info.nested_descend_keys = descend_keys.clone(); + let mut client_ids: Vec<ClientId> = self.mode_info.keys().copied().collect(); + for connected_client_id in self.connected_clients.borrow().keys() { + if !client_ids.contains(connected_client_id) { + client_ids.push(*connected_client_id); + } + } + for client_id in client_ids { + let mode_info = self + .mode_info + .entry(client_id) + .or_insert_with(|| self.default_mode_info.clone()); + mode_info.session_ancestry = ancestry.clone(); + mode_info.host_fullscreen = host_fullscreen; + mode_info.session_ascended = session_ascended; + mode_info.nested_descend_keys = descend_keys.clone(); + let mode_info = mode_info.clone(); + for tab in self.tabs.values_mut() { + tab.change_mode_info(mode_info.clone(), client_id); + } + } + for tab in self.tabs.values_mut() { + let _ = tab.update_input_modes(); + } + let _ = self.render(None); + } + + fn nested_guest_terminal_ids(&self) -> Vec<u32> { + let mut terminal_ids = vec![]; + for tab in self.tabs.values() { + for pane_id in tab.nested_guest_pane_ids() { + if let PaneId::Terminal(terminal_id) = pane_id { + if self.nested_guest_tracker.is_tracked(pane_id) { + terminal_ids.push(terminal_id); + } + } + } + } + terminal_ids + } + + fn broadcast_ancestry_update_to_guests(&self) { + let mut ancestry = self.nested_ancestry.clone(); + ancestry.push(self.session_name.clone()); + let message = NestedSessionMessage::AncestryUpdate { ancestry }; + let frame = nested_session::encode_frame(&message); + for terminal_id in self.nested_guest_terminal_ids() { + let _ = self + .bus + .senders + .send_to_pty_writer(PtyWriteInstruction::Write(frame.clone(), terminal_id, None)); + } + } + + fn apply_nested_guest_fullscreen(&mut self, pane_id: PaneId, fullscreen: bool) { + let mut actually_fullscreen = false; + let mut displaced_pane_id = None; + if let Some(tab) = self + .tabs + .values_mut() + .find(|tab| tab.has_pane_with_pid(&pane_id)) + { + let currently_fullscreen = + tab.fullscreen_pane_id() == Some(pane_id) && tab.fullscreen_covers_ui(); + if fullscreen && !currently_fullscreen { + let existing_fullscreen = tab.fullscreen_pane_id(); + if existing_fullscreen.is_some() && existing_fullscreen != Some(pane_id) { + displaced_pane_id = existing_fullscreen; + tab.toggle_pane_no_ui_fullscreen(existing_fullscreen.unwrap()); + } + tab.toggle_pane_no_ui_fullscreen(pane_id); + } else if !fullscreen && currently_fullscreen { + tab.toggle_pane_no_ui_fullscreen(pane_id); + } + actually_fullscreen = + tab.fullscreen_pane_id() == Some(pane_id) && tab.fullscreen_covers_ui(); + } + if let Some(displaced_pane_id) = displaced_pane_id { + self.nested_fullscreen_panes.remove(&displaced_pane_id); + } + if actually_fullscreen { + self.nested_fullscreen_panes.insert(pane_id); + } else { + self.nested_fullscreen_panes.remove(&pane_id); + } + } + + fn unset_nested_fullscreen_if_active(&mut self, pane_id: PaneId) { + if !self.nested_fullscreen_panes.contains(&pane_id) { + return; + } + self.apply_nested_guest_fullscreen(pane_id, false); + if let Some(client_id) = self.nested_via_client_id { + let payload = + nested_session::encode_payload(&NestedSessionMessage::ToggleHostFullscreen { + fullscreen: false, + }); + let _ = + self.bus + .senders + .send_to_server(ServerInstruction::EmitNestedSessionFrameToClient( + client_id, payload, + )); + } + } + + fn sync_nested_guest_fullscreen_state(&mut self) { + let mut actual: HashMap<PaneId, bool> = HashMap::new(); + for tab in self.tabs.values() { + let fullscreen_pane_id = tab.fullscreen_pane_id(); + let covers_ui = tab.fullscreen_covers_ui(); + for pane_id in tab.nested_guest_pane_ids() { + if !self.nested_guest_tracker.is_tracked(pane_id) { + continue; + } + let is_fullscreen = covers_ui && fullscreen_pane_id == Some(pane_id); + actual.insert(pane_id, is_fullscreen); + } + } + let mut updates: Vec<(u32, bool)> = vec![]; + for (pane_id, is_fullscreen) in actual.iter() { + let previously = self.reported_guest_fullscreen.get(pane_id).copied(); + if previously != Some(*is_fullscreen) { + if let PaneId::Terminal(terminal_id) = pane_id { + updates.push((*terminal_id, *is_fullscreen)); + } + self.reported_guest_fullscreen + .insert(*pane_id, *is_fullscreen); + } + } + self.reported_guest_fullscreen + .retain(|pane_id, _| actual.contains_key(pane_id)); + for (pane_id, is_fullscreen) in actual.iter() { + if !is_fullscreen { + self.downgrade_zoom_choice_to_descend(*pane_id); + } + } + for (terminal_id, fullscreen) in updates { + let message = NestedSessionMessage::FullscreenState { fullscreen }; + let _ = self + .bus + .senders + .send_to_pty_writer(PtyWriteInstruction::Write( + nested_session::encode_frame(&message), + terminal_id, + None, + )); + } + } + + fn clients_with_pane_focused(&self, pane_id: PaneId) -> Vec<ClientId> { + let mut clients = vec![]; + for (client_id, tab_id) in self.active_tab_ids.iter() { + if let Some(focused) = self + .tabs + .get(tab_id) + .and_then(|t| t.get_active_pane_id(*client_id)) + { + if focused == pane_id { + clients.push(*client_id); + } + } + } + clients + } + + fn bubble_focus_to_host(&self, client_id: ClientId, direction: Direction) { + let payload = nested_session::encode_payload(&NestedSessionMessage::FocusHost { + direction: Some(direction), + }); + let _ = self + .bus + .senders + .send_to_server(ServerInstruction::EmitNestedSessionFrameToClient( + client_id, payload, + )); + } + + pub fn handle_nested_session_message_from_host( + &mut self, + client_id: ClientId, + message: NestedSessionMessage, + ) { + match message { + NestedSessionMessage::AnnounceAck { + ancestry, + descend_keys, + .. + } => { + log::info!( + "nested session handshake complete: this session is nested inside ancestry {:?}", + ancestry + ); + self.nested_ancestry = ancestry; + self.nested_via_client_id = Some(client_id); + self.host_descend_keys = descend_keys; + self.update_all_clients_nesting_mode_info(); + self.broadcast_ancestry_update_to_guests(); + let payload = + nested_session::encode_payload(&NestedSessionMessage::ShortcutUpdate { + ascend_keys: self.own_ascend_shortcut(), + descend_keys: vec![], + }); + let _ = self.bus.senders.send_to_server( + ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload), + ); + }, + NestedSessionMessage::ShortcutUpdate { descend_keys, .. } => { + if self.host_descend_keys != descend_keys { + self.host_descend_keys = descend_keys; + self.update_all_clients_nesting_mode_info(); + } + }, + NestedSessionMessage::FullscreenState { fullscreen } => { + self.host_fullscreen = fullscreen; + self.own_fullscreen_requested = fullscreen; + self.update_all_clients_nesting_mode_info(); + }, + NestedSessionMessage::AncestryUpdate { ancestry } => { + self.nested_ancestry = ancestry; + self.update_all_clients_nesting_mode_info(); + self.broadcast_ancestry_update_to_guests(); + }, + NestedSessionMessage::FocusGained { + from_direction: Some(direction), + } => { + self.set_host_descended(true); + let old_pane_id = self.get_active_pane_id(&client_id); + if let Ok(tab) = self.get_active_tab_mut(client_id) { + tab.focus_pane_on_edge(direction, client_id); + } + let new_pane_id = self.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + self.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(direction), + ); + } + let _ = self.render(None); + let _ = self.log_and_report_session_state(); + }, + NestedSessionMessage::FocusGained { + from_direction: None, + } => { + self.set_host_descended(true); + let _ = self.render(None); + }, + NestedSessionMessage::FocusLost => { + self.set_host_descended(false); + let _ = self.render(None); + }, + other => { + log::debug!( + "dropping unsupported nested session message from host: {:?}", + other + ); + }, + } + } + + pub fn handle_nested_guest_ping_tick(&mut self, pane_id: PaneId) { + use crate::nested_guest::GuestPingTickAction; + match self.nested_guest_tracker.on_tick(pane_id, Instant::now()) { + GuestPingTickAction::SendPing => { + if let PaneId::Terminal(terminal_id) = pane_id { + let _ = self + .bus + .senders + .send_to_pty_writer(PtyWriteInstruction::Write( + nested_session::encode_frame(&NestedSessionMessage::Ping), + terminal_id, + None, + )); + log::debug!("pinged nested session guest in pane {:?}", pane_id); + } + }, + GuestPingTickAction::Suspended => { + log::info!( + "nested session guest in pane {:?} missed the pong timeout, suspending until it re-announces", + pane_id + ); + self.suspend_nested_guest(pane_id); + }, + GuestPingTickAction::Unknown => { + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::StopNestedGuestPing(pane_id)); + }, + } + } + + /// Build a reply for `query` from whatever host state Zellij has + /// cached, or an empty `Vec` if nothing relevant is cached. The + /// classification happened at intercept time (Grid produces a + /// [`HostQuery`]), so this method is pure structural dispatch — + /// no re-parsing of bytes. + fn synthesize_cached_reply(&self, query: &crate::host_query::HostQuery) -> Vec<u8> { + use crate::host_query::HostQuery; + match query { + HostQuery::TextAreaPixelSize => self + .pixel_dimensions + .text_area_size + .map(|dims| format!("\x1b[4;{};{}t", dims.height, dims.width).into_bytes()) + .unwrap_or_default(), + HostQuery::CharacterCellPixelSize => self + .pixel_dimensions + .character_cell_size + .map(|dims| format!("\x1b[6;{};{}t", dims.height, dims.width).into_bytes()) + .unwrap_or_default(), + HostQuery::DefaultForeground { terminator } + | HostQuery::DefaultBackground { terminator } => { + let palette = self.terminal_emulator_colors.borrow(); + let (channel, color) = match query { + HostQuery::DefaultForeground { .. } => (10u32, palette.fg), + HostQuery::DefaultBackground { .. } => (11u32, palette.bg), + _ => unreachable!(), + }; + if let PaletteColor::Rgb((r, g, b)) = color { + let mut out = format!( + "\x1b]{};rgb:{:04x}/{:04x}/{:04x}", + channel, + (r as u16) * 0x0101, + (g as u16) * 0x0101, + (b as u16) * 0x0101, + ) + .into_bytes(); + out.extend_from_slice(terminator.as_bytes()); + out + } else { + Vec::new() + } + }, + HostQuery::PaletteRegister { index, terminator } => { + let codes = self.terminal_emulator_color_codes.borrow(); + if let Some(color) = codes.get(&(*index as usize)) { + let mut out = format!("\x1b]4;{};{}", index, color).into_bytes(); + out.extend_from_slice(terminator.as_bytes()); + out + } else { + Vec::new() + } + }, + // Should not reach here: ColorPaletteMode short-circuits in + // `forward_host_query` before any cache-fallback path runs. + HostQuery::ColorPaletteMode => match self.effective_host_terminal_theme_mode() { + HostTerminalThemeMode::Dark => b"\x1b[?997;1n".to_vec(), + HostTerminalThemeMode::Light => b"\x1b[?997;2n".to_vec(), + }, + } + } + + pub fn render(&mut self, plugin_render_assets: Option<Vec<PluginRenderAsset>>) -> Result<()> { + // here we schedule the RenderToClients background job which debounces renders every 10ms + // rather than actually rendering + // + // when this job decides to render, it sends back the ScreenInstruction::RenderToClients + // message, triggering our render_to_clients method which does the actual rendering + + self.sync_nested_guest_fullscreen_state(); + + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::RenderToClients); + if let Some(plugin_render_assets) = plugin_render_assets { + let _ = self + .bus + .senders + .send_to_plugin(PluginInstruction::UnblockCliPipes(plugin_render_assets)) + .context("failed to unblock input pipe"); + } + Ok(()) + } + + pub fn render_to_clients(&mut self) -> Result<()> { + // this method does the actual rendering and is triggered by a debounced BackgroundJob (see + // the render method for more details) + let err_context = "failed to render screen"; + + // Separate rendering for regular clients and watchers + let has_regular_clients = self + .connected_clients + .borrow() + .keys() + .any(|id| !self.watcher_clients.contains_key(id)); + let has_watchers = !self.watcher_clients.is_empty(); // No change needed + + // Track whether non-watcher output was dirty for conditional watcher rendering + let non_watcher_output_was_dirty; + + let mut tabs_to_close = vec![]; + + // === PHASE 1: Render for regular clients === + if has_regular_clients { + let mut output = Output::new( + self.sixel_image_store.clone(), + self.character_cell_size.clone(), + self.styled_underlines, + self.osc8_hyperlinks, + self.kitty_image_store.clone(), + self.kitty_host_capabilities.clone(), + self.client_kitty_host_state.clone(), + ); + + let has_ansi_subscribers = self.pane_render_subscribers.values().any(|s| s.ansi); + output.collect_ansi_pane_contents = + has_ansi_subscribers || self.plugins_need_ansi_pane_contents; + + for (tab_index, tab) in &mut self.tabs { + if tab.has_selectable_tiled_panes() { + // Pass None for normal client rendering + tab.render(&mut output, None).context(err_context)?; + } else if !tab.is_pending() { + tabs_to_close.push(*tab_index); + } + } + + if self.plugins_need_ansi_pane_contents { + let all_regular_clients: Vec<ClientId> = self + .connected_clients + .borrow() + .keys() + .copied() + .filter(|id| !self.watcher_clients.contains_key(id)) + .collect(); + if !all_regular_clients.is_empty() { + for tab in self.tabs.values() { + for pane_id in tab.get_static_and_floating_pane_ids() { + if let Some(pane) = tab.get_pane_with_id(pane_id) { + match pane_id { + PaneId::Terminal(_) => { + let contents = + pane.pane_contents_with_ansi(None, false, None); + output.add_pane_contents_with_ansi( + &all_regular_clients, + pane_id, + contents, + ); + }, + PaneId::Plugin(_) => { + for client_id in &all_regular_clients { + let contents = pane.pane_contents_with_ansi( + Some(*client_id), + false, + None, + ); + output.add_pane_contents_with_ansi( + &[*client_id], + pane_id, + contents, + ); + } + }, + } + } + } + } + } + } + + 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 + .send_to_plugin(PluginInstruction::PaneRenderReport(pane_render_report)); + + non_watcher_output_was_dirty = output.is_dirty(); + + let mut bell_state_changed = false; + let mut has_bell = false; + + if self.visual_bell { + let mut panes_to_flash: Vec<PaneId> = vec![]; + let mut tabs_to_flash: Vec<usize> = vec![]; + + let active_tab_ids_snapshot: Vec<usize> = + self.active_tab_ids.values().copied().collect(); + + for tab in self.tabs.values_mut() { + let is_active = active_tab_ids_snapshot.contains(&tab.id); + let (new_panes, tab_newly_set) = + tab.check_and_handle_bell_notifications(is_active); + if !new_panes.is_empty() { + panes_to_flash.extend(new_panes); + bell_state_changed = true; + } + if tab_newly_set { + tabs_to_flash.push(tab.id); + bell_state_changed = true; + } + } + + has_bell = !panes_to_flash.is_empty() || !tabs_to_flash.is_empty(); + if !panes_to_flash.is_empty() { + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::FlashPaneBell(panes_to_flash)); + } + for tab_id in tabs_to_flash { + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::FlashTabBell(tab_id)); + } + } else { + // visual_bell disabled: still detect bell for ANSI BEL forwarding only + for tab in self.tabs.values_mut() { + if tab.check_and_consume_bells_without_visual_notification() { + has_bell = true; + } + } + } + + if has_bell { + output.add_post_vte_instruction_to_multiple_clients( + self.active_tab_ids.keys().copied(), + "\u{7}", // ANSI BEL + ); + } + + for (client_id, tab_index) in &self.active_tab_ids { + if self.watcher_clients.contains_key(client_id) { + continue; + } + if let Some(tab) = self.tabs.get(tab_index) { + output.set_kitty_visible_panes(*client_id, tab.kitty_visible_pane_ids()); + } + } + + if non_watcher_output_was_dirty || has_bell { + let mut serialized_output = output.serialize().context(err_context)?; + self.mobile_render_gate + .blank_gated_clients(&mut serialized_output); + if !serialized_output.is_empty() { + let _ = self + .bus + .senders + .send_to_server(ServerInstruction::Render(Some(serialized_output))) + .context(err_context); + } + } + + let single_pane_names_changed = self.update_single_pane_tab_names(); + if bell_state_changed || single_pane_names_changed { + self.log_and_report_session_state()?; + } + } 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 === + if has_watchers { + if let Some(followed_client_id) = self.followed_client_id { + // Create fresh output for watchers + let mut watcher_output = Output::new( + self.sixel_image_store.clone(), + self.character_cell_size.clone(), + self.styled_underlines, + self.osc8_hyperlinks, + self.kitty_image_store.clone(), + self.kitty_host_capabilities.clone(), + self.client_kitty_host_state.clone(), + ); + + let focused_tab_index_of_followed_client_id = + *self.active_tab_ids.get(&followed_client_id).unwrap_or(&0); + + if let Some(tab) = self + .tabs + .get_mut(&focused_tab_index_of_followed_client_id) + .as_mut() + { + // Only force render if: + // 1. Non-watcher output was dirty, OR + // 2. Any watcher needs a forced render (first render or after resize), OR + // 3. No non-watcher clients are connected + let any_watcher_needs_force_render = self + .watcher_clients + .values() + .any(|state| state.should_force_render()); + let should_force_render = non_watcher_output_was_dirty + || any_watcher_needs_force_render + || !has_regular_clients; + + if should_force_render { + tab.set_force_render(); + } + tab.render(&mut watcher_output, Some(followed_client_id)) + .context(err_context)?; + } + + // Send the rendered output to all watcher clients + if watcher_output.is_dirty() { + let mut watcher_render_output: HashMap<ClientId, String> = HashMap::new(); + + // For each watcher, clone the output and serialize with size constraints + for (watcher_id, watcher_state) in &self.watcher_clients { + let mut watcher_specific_output = watcher_output.clone(); + + // Serialize this watcher's output with size constraints (cropping and padding handled inside) + let mut serialized_output = watcher_specific_output + .serialize_with_size(Some(watcher_state.size()), Some(self.size)) + .context(err_context)?; + + // Get the output for the followed client and map it to this watcher + if let Some(followed_output) = serialized_output.remove(&followed_client_id) + { + watcher_render_output.insert(*watcher_id, followed_output); + } + } + + // Send to server for delivery to watcher clients + if !watcher_render_output.is_empty() { + let _ = self + .bus + .senders + .send_to_server(ServerInstruction::Render(Some(watcher_render_output))) + .context(err_context); + } + + // Clear force render flag for all watchers after successful render + for watcher_state in self.watcher_clients.values_mut() { + watcher_state.clear_force_render(); + } + } + } + } + for tab_index in tabs_to_close { + self.close_tab_by_id(tab_index) + .context(err_context) + .non_fatal(); + } + + Ok(()) + } + + /// Returns a mutable reference to this [`Screen`]'s tabs. + pub fn get_tabs_mut(&mut self) -> &mut BTreeMap<usize, Tab> { + &mut self.tabs + } + + pub fn get_tabs(&self) -> &BTreeMap<usize, Tab> { + &self.tabs + } + + /// Returns an immutable reference to this [`Screen`]'s active [`Tab`]. + pub fn get_active_tab(&self, client_id: ClientId) -> Result<&Tab> { + match self.active_tab_ids.get(&client_id) { + Some(tab) => self + .tabs + .get(tab) + .ok_or_else(|| anyhow!("active tab {} does not exist", tab)), + None => Err(anyhow!("active tab not found for client {:?}", client_id)), + } + } + + pub fn get_client_input_mode(&self, client_id: ClientId) -> Option<InputMode> { + self.get_active_tab(client_id) + .ok() + .and_then(|tab| tab.get_client_input_mode(client_id)) + } + + pub fn get_first_client_id(&self) -> Option<ClientId> { + self.active_tab_ids.keys().next().copied() + } + + /// Returns an immutable reference to this [`Screen`]'s previous active [`Tab`]. + /// Consumes the last entry in tab history. + pub fn get_previous_tab(&mut self, client_id: ClientId) -> Result<Option<&Tab>> { + Ok( + match self + .tab_history + .get_mut(&client_id) + .with_context(|| { + format!("failed to retrieve tab history for client {client_id:?}") + })? + .pop() + { + Some(tab) => self.tabs.get(&tab), + None => None, + }, + ) + } + + /// Returns a mutable reference to this [`Screen`]'s active [`Tab`]. + pub fn get_active_tab_mut(&mut self, client_id: ClientId) -> Result<&mut Tab> { + match self.active_tab_ids.get(&client_id) { + Some(tab) => self + .tabs + .get_mut(tab) + .ok_or_else(|| anyhow!("active tab {} does not exist", tab)), + None => Err(anyhow!("active tab not found for client {:?}", client_id)), + } + } + + /// Returns a mutable reference to this [`Screen`]'s indexed [`Tab`]. + pub fn get_indexed_tab_mut(&mut self, tab_index: usize) -> Option<&mut Tab> { + self.get_tabs_mut().get_mut(&tab_index) + } + + /// Clear bell notification for the currently focused pane of the given client. + /// Also cancels any running flash jobs if applicable. + pub fn clear_bell_for_focused_pane(&mut self, client_id: ClientId) { + let tab_id_and_pane_id: Option<(usize, PaneId)> = + self.get_active_tab_mut(client_id).ok().and_then(|tab| { + tab.get_active_pane_id(client_id) + .map(|pane_id| (tab.id, pane_id)) + }); + if let Some((tab_id, focused_pane_id)) = tab_id_and_pane_id { + if let Some(tab) = self.tabs.get_mut(&tab_id) { + if tab.panes_with_pending_bell.contains(&focused_pane_id) { + let tab_had_bell = tab.tab_has_pending_bell; + tab.clear_bell_notification_for_pane(focused_pane_id); + let tab_bell_now_cleared = tab_had_bell && !tab.tab_has_pending_bell; + let _ = + self.bus + .senders + .send_to_background_jobs(BackgroundJob::StopFlashPaneBell(vec![ + focused_pane_id, + ])); + if tab_bell_now_cleared { + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::StopFlashTabBell(tab_id)); + } + } + } + } + } + + /// Clear bell notification for a specific pane ID in the given client's active tab. + pub fn clear_bell_for_pane_id(&mut self, pane_id: PaneId, client_id: ClientId) { + let tab_id: Option<usize> = self.get_active_tab_mut(client_id).ok().map(|tab| tab.id); + if let Some(tab_id) = tab_id { + if let Some(tab) = self.tabs.get_mut(&tab_id) { + if tab.panes_with_pending_bell.contains(&pane_id) { + let tab_had_bell = tab.tab_has_pending_bell; + tab.clear_bell_notification_for_pane(pane_id); + let tab_bell_now_cleared = tab_had_bell && !tab.tab_has_pending_bell; + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::StopFlashPaneBell(vec![pane_id])); + if tab_bell_now_cleared { + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::StopFlashTabBell(tab_id)); + } + } + } + } + } + + pub fn show_floating_panes_in_tab( + &mut self, + client_id: ClientId, + tab_id: Option<usize>, + completion: Option<NotificationEnd>, + ) -> Result<()> { + let tab = match tab_id { + Some(id) => self.tabs.get_mut(&id), + None => self.get_active_tab_mut(client_id).ok(), + }; + match tab { + None => { + let mut completion = completion; + if let Some(c) = completion.as_mut() { + c.set_exit_status(1); + c.set_error_message("Tab not found".to_string()); + } + drop(completion); + }, + Some(tab) => tab.show_floating_panes_atomic(completion), + } + Ok(()) + } + + pub fn hide_floating_panes_in_tab( + &mut self, + client_id: ClientId, + tab_id: Option<usize>, + completion: Option<NotificationEnd>, + ) -> Result<()> { + let tab = match tab_id { + Some(id) => self.tabs.get_mut(&id), + None => self.get_active_tab_mut(client_id).ok(), + }; + match tab { + None => { + let mut completion = completion; + if let Some(c) = completion.as_mut() { + c.set_exit_status(1); + c.set_error_message("Tab not found".to_string()); + } + drop(completion); + }, + Some(tab) => tab.hide_floating_panes_atomic(completion), + } + Ok(()) + } + + pub fn are_floating_panes_visible_in_tab( + &self, + client_id: ClientId, + tab_id: Option<usize>, + completion: Option<NotificationEnd>, + ) -> Result<()> { + let tab = match tab_id { + Some(id) => self.tabs.get(&id), + None => self.get_active_tab(client_id).ok(), + }; + let mut completion = completion; + match tab { + None => { + if let Some(c) = completion.as_mut() { + c.set_error_message("Tab not found".to_string()); + } + }, + Some(tab) => { + if tab.are_floating_panes_visible() { + if let Some(c) = completion.as_mut() { + c.set_stdout_message("true".to_string()); + } + } else { + if let Some(c) = completion.as_mut() { + c.set_error_message("false".to_string()); + } + } + }, + } + drop(completion); + Ok(()) + } + + /// Creates a new [`Tab`] in this [`Screen`] + pub fn new_tab( + &mut self, + tab_id: usize, + swap_layouts: (Vec<SwapTiledLayout>, Vec<SwapFloatingLayout>), + tab_name: Option<String>, + client_id: Option<ClientId>, + ) -> Result<()> { + let err_context = || format!("failed to create new tab for client {client_id:?}",); + + let client_id = client_id.map(|client_id| { + if self.get_active_tab(client_id).is_ok() { + client_id + } else if let Some(first_client_id) = self.get_first_client_id() { + first_client_id + } else { + client_id + } + }); + + let tab_name = tab_name.unwrap_or_else(|| String::new()); + let mobile_tab_count = self.mobile_state.mobile_tab_count(); + + let position = self.tabs.len(); + let mut tab = Tab::new( + tab_id, + position, + tab_name, + self.size, + self.character_cell_size.clone(), + self.stacked_resize.clone(), + self.stacked_pane_list.clone(), + self.sixel_image_store.clone(), + self.kitty_image_store.clone(), + self.bus + .os_input + .as_ref() + .with_context(err_context)? + .clone(), + self.bus.senders.clone(), + self.max_panes, + self.style, + self.default_mode_info.clone(), + self.pane_frame_style, + self.auto_layout, + self.connected_clients.clone(), + self.session_is_mirrored, + client_id, + self.copy_options.clone(), + self.terminal_emulator_colors.clone(), + self.terminal_emulator_color_codes.clone(), + swap_layouts, + self.default_shell.clone(), + self.debug, + self.arrow_fonts, + self.styled_underlines, + self.osc8_hyperlinks, + self.explicitly_disable_kitty_keyboard_protocol, + self.default_editor.clone(), + self.web_clients_allowed, + self.web_sharing, + self.current_pane_group.clone(), + self.currently_marking_pane_group.clone(), + self.advanced_mouse_actions, + self.mouse_scroll_resize, + self.mouse_hover_effects, + self.focus_follows_mouse, + self.mouse_click_through, + self.web_server_ip, + self.web_server_port, + mobile_tab_count, + ); + for (client_id, mode_info) in &self.mode_info { + tab.change_mode_info(mode_info.clone(), *client_id); + } + for dimmed_client_id in &self.dimmed_clients { + tab.set_client_dimmed(*dimmed_client_id, true); + } + if let Some(aggregate) = self.kitty_host_support_aggregate() { + tab.update_kitty_host_support(aggregate); + } + self.tabs.insert(tab_id, tab); + Ok(()) + } + pub fn apply_layout( + &mut self, + layout: TiledPaneLayout, + floating_panes_layout: Vec<FloatingPaneLayout>, + new_terminal_ids: Vec<(u32, HoldForCommand)>, + new_floating_terminal_ids: Vec<(u32, HoldForCommand)>, + new_plugin_ids: HashMap<RunPluginOrAlias, Vec<u32>>, + tab_id: usize, + should_change_client_focus: bool, + client_id_and_is_web_client: (ClientId, bool), + blocking_terminal: Option<(u32, NotificationEnd)>, + ) -> Result<()> { + if self.tabs.get(&tab_id).is_none() { + // TODO: we should prevent this situation with a UI - eg. cannot close tabs with a + // pending state + log::error!("Tab with index {tab_id} not found. Cannot apply layout!"); + return Ok(()); + } + let (client_id, mut is_web_client) = client_id_and_is_web_client; + let client_id = if self.get_active_tab(client_id).is_ok() { + if let Some(connected_client_is_web_client) = + self.connected_clients.borrow().get(&client_id) + { + is_web_client = *connected_client_is_web_client; + } + client_id + } else if let Some(first_client_id) = self.get_first_client_id() { + if let Some(first_client_is_web_client) = + self.connected_clients.borrow().get(&first_client_id) + { + is_web_client = *first_client_is_web_client; + } + first_client_id + } else { + client_id + }; + let err_context = || format!("failed to apply layout for tab {tab_id:?}",); + + let passthrough_affected_client_ids: Vec<ClientId> = if should_change_client_focus { + if self.session_is_mirrored { + self.connected_clients + .borrow() + .iter() + .map(|(c, _i)| *c) + .collect() + } else { + vec![client_id] + } + } else { + vec![] + }; + let passthrough_old_focused_panes: Vec<(ClientId, Option<PaneId>)> = + passthrough_affected_client_ids + .iter() + .map(|c| (*c, self.get_active_pane_id(c))) + .collect(); + + // move the relevant clients out of the current tab and place them in the new one + let drained_clients = if should_change_client_focus { + if self.session_is_mirrored { + let client_mode_infos_in_source_tab = if let Ok(active_tab) = + self.get_active_tab_mut(client_id) + { + let client_mode_infos_in_source_tab = active_tab.drain_connected_clients(None); + if active_tab.has_no_connected_clients() { + active_tab + .visible(false) + .with_context(err_context) + .non_fatal(); + } + Some(client_mode_infos_in_source_tab) + } else { + None + }; + let all_connected_clients: Vec<ClientId> = self + .connected_clients + .borrow() + .iter() + .map(|(c, _i)| *c) + .collect(); + for client_id in all_connected_clients { + self.update_client_tab_focus(client_id, tab_id); + } + client_mode_infos_in_source_tab + } else if let Ok(active_tab) = self.get_active_tab_mut(client_id) { + let client_mode_info_in_source_tab = + active_tab.drain_connected_clients(Some(vec![client_id])); + if active_tab.has_no_connected_clients() { + active_tab + .visible(false) + .with_context(err_context) + .non_fatal(); + } + self.update_client_tab_focus(client_id, tab_id); + Some(client_mode_info_in_source_tab) + } else { + None + } + } else { + None + }; + + if !self.active_tab_ids.contains_key(&client_id) { + self.connected_clients + .borrow_mut() + .insert(client_id, is_web_client); + } + + // apply the layout to the new tab + self.tabs + .get_mut(&tab_id) + .context("couldn't find tab with index {tab_id}") + .and_then(|tab| { + tab.apply_layout( + layout, + floating_panes_layout, + new_terminal_ids, + new_floating_terminal_ids, + new_plugin_ids, + client_id, + blocking_terminal, + )?; + tab.update_input_modes()?; + + if let Some(drained_clients) = drained_clients { + tab.visible(true)?; + tab.add_multiple_clients(drained_clients)?; + } + tab.resize_whole_tab(self.size).with_context(err_context)?; + tab.set_force_render(); + Ok(()) + }) + .with_context(err_context)?; + + if !self.active_tab_ids.contains_key(&client_id) { + // this means this is a new client and we need to add it to our state properly + self.add_client(client_id, is_web_client) + .with_context(err_context)?; + } + + self.recompute_tab_size(tab_id).with_context(err_context)?; + + for (affected_client_id, old_pane_id) in passthrough_old_focused_panes { + let new_pane_id = self.get_active_pane_id(&affected_client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + self.report_key_passthrough_state(affected_client_id, old, new); + } + } + + self.log_and_report_session_state() + .and_then(|_| self.render(None)) + .with_context(err_context) + } + + pub fn add_client(&mut self, client_id: ClientId, is_web_client: bool) -> Result<()> { + let err_context = |tab_index| { + format!("failed to attach client {client_id} to tab with index {tab_index}") + }; + + // Set followed_client_id to the first regular client if not already set + if self.followed_client_id.is_none() && !self.watcher_clients.contains_key(&client_id) { + self.followed_client_id = Some(client_id); + } + + let mut tab_history = vec![]; + if let Some((_first_client, first_tab_history)) = self.tab_history.iter().next() { + tab_history = first_tab_history.clone(); + } + + let tab_index = if let Some((_first_client, first_active_tab_index)) = + self.active_tab_ids.iter().next() + { + *first_active_tab_index + } else if self.tabs.contains_key(&self.global_last_active_tab_id) { + self.global_last_active_tab_id + } else if self.tabs.contains_key(&0) { + 0 + } else if let Some(tab_index) = self.tabs.keys().next() { + tab_index.to_owned() + } else { + bail!("Can't find a valid tab to attach client to!"); + }; + + self.active_tab_ids.insert(client_id, tab_index); + self.client_kitty_host_state.borrow_mut().remove(&client_id); + self.connected_clients + .borrow_mut() + .insert(client_id, is_web_client); + if is_web_client { + self.kitty_host_capabilities + .borrow_mut() + .insert(client_id, false); + self.push_kitty_host_support_to_tabs(); + } + self.tab_history.insert(client_id, tab_history); + self.tabs + .get_mut(&tab_index) + .with_context(|| err_context(tab_index))? + .add_client(client_id, None) + .with_context(|| err_context(tab_index))?; + self.recompute_tab_size(tab_index) + .with_context(|| err_context(tab_index))?; + if !self.nested_ancestry.is_empty() || !self.host_descend_keys.is_empty() { + self.update_all_clients_nesting_mode_info(); + } + Ok(()) + } + + pub fn remove_client(&mut self, client_id: ClientId) -> Result<()> { + let err_context = || format!("failed to remove client {client_id}"); + + self.set_client_dimmed(client_id, false, None); + let passthrough_panes: Vec<PaneId> = self + .nested_guest_choices + .iter() + .filter(|((choice_client_id, _), choice)| { + *choice_client_id == client_id && choice.enters_passthrough() + }) + .map(|((_, choice_pane_id), _)| *choice_pane_id) + .collect(); + for pane_id in passthrough_panes { + let _ = self + .bus + .senders + .send_to_server(ServerInstruction::KeyPassthroughChanged( + client_id, pane_id, pane_id, false, None, true, + )); + } + self.nested_guest_choices + .retain(|(choice_client_id, _), _| *choice_client_id != client_id); + for tab in self.tabs.values_mut() { + tab.clear_guest_modal_for_client_on_all_panes(client_id); + tab.clear_guest_choice_indicator_for_client_on_all_panes(client_id); + } + + // If the followed client disconnected, find the next regular client + if Some(client_id) == self.followed_client_id { + // Try to find another regular (non-watcher) client + self.followed_client_id = self + .connected_clients + .borrow() + .keys() + .copied() + .find(|id| !self.watcher_clients.contains_key(id) && id != &client_id); + + // If no regular client remains but we have watchers, keep the old followed_client_id + // for terminal rendering (plugins will use their last state) + if self.followed_client_id.is_none() && !self.watcher_clients.is_empty() { + self.followed_client_id = Some(client_id); // Keep the disconnected client's ID + } + } + + self.mobile_state.forget_client(client_id); + self.mobile_render_gate.ungate(client_id); + + let to_recompute = self + .mobile_state + .clear_fits_owned_by(client_id, &mut self.tabs); + for tab_id in to_recompute { + self.recompute_tab_size(tab_id).with_context(err_context)?; + } + + let mut tabs_to_garbage_collect: Vec<usize> = vec![]; + for (tab_id, tab) in self.tabs.iter_mut() { + tab.remove_client(client_id); + if tab.has_no_connected_clients() { + tab.visible(false).with_context(err_context)?; + } + if let Some(set) = tab.visible_to.as_mut() { + set.remove(&client_id); + if set.is_empty() { + tabs_to_garbage_collect.push(*tab_id); + } + } + } + + self.mobile_state + .clear_shadow_focus(client_id, &mut self.tabs); + let previously_active_tab_id = self.active_tab_ids.get(&client_id).copied(); + if let Some(prev_tab_id) = previously_active_tab_id { + self.global_last_active_tab_id = prev_tab_id; + self.active_tab_ids.remove(&client_id); + } + if self.tab_history.contains_key(&client_id) { + self.tab_history.remove(&client_id); + } + self.connected_clients.borrow_mut().remove(&client_id); + self.client_kitty_host_state.borrow_mut().remove(&client_id); + let removed_kitty_capability = self + .kitty_host_capabilities + .borrow_mut() + .remove(&client_id) + .is_some(); + if removed_kitty_capability { + self.push_kitty_host_support_to_tabs(); + } + self.client_sizes.remove(&client_id); + self.pane_render_subscribers.remove(&client_id); + if let Some(prev_tab_id) = previously_active_tab_id { + self.recompute_tab_size(prev_tab_id) + .with_context(err_context)?; + } + for tab_id in tabs_to_garbage_collect { + if self.tabs.contains_key(&tab_id) { + self.close_tab_by_id(tab_id).with_context(err_context)?; + } + } + if self.nested_via_client_id == Some(client_id) { + self.nested_via_client_id = None; + self.nested_ancestry = vec![]; + self.host_descended = false; + self.host_descend_keys = vec![]; + self.update_all_clients_nesting_mode_info(); + self.broadcast_ancestry_update_to_guests(); + } + self.log_and_report_session_state().non_fatal(); + Ok(()) + } + + pub fn add_watcher_client(&mut self, client_id: ClientId) -> Result<()> { + // Initialize with a default size - will be updated when we receive the actual size + let default_size = Size { rows: 24, cols: 80 }; // Reasonable default + self.watcher_clients + .insert(client_id, WatcherState::new(default_size)); + + // Force a full render for the new watcher + // This ensures they get complete state, not just delta + self.render(None)?; + + Ok(()) + } + + pub fn remove_watcher_client(&mut self, client_id: ClientId) { + self.watcher_clients.remove(&client_id); + } + + pub fn set_followed_client(&mut self, client_id: ClientId) -> Result<()> { + self.followed_client_id = Some(client_id); + // Trigger re-render with new followed client + self.render(None)?; + Ok(()) + } + + pub fn set_watcher_size(&mut self, client_id: ClientId, size: Size) { + // Update size if this client is a watcher + if let Some(watcher_state) = self.watcher_clients.get_mut(&client_id) { + watcher_state.set_size(size); + watcher_state.set_force_render(); + } + } + + pub fn generate_and_report_tab_state(&mut self) -> Result<Vec<TabInfo>> { + let mut plugin_updates = vec![]; + let mut tab_infos_for_screen_state = BTreeMap::new(); + for tab in self.tabs.values() { + let mut all_focused_clients: Vec<ClientId> = self + .active_tab_ids + .iter() + .filter(|(_c_id, tab_position)| **tab_position == tab.id) + .map(|(c_id, _)| c_id) + .copied() + .collect(); + for s_id in tab.shadow_focus_clients() { + if !all_focused_clients.contains(&s_id) { + all_focused_clients.push(s_id); + } + } + let (active_swap_layout_name, is_swap_layout_dirty) = tab.swap_layout_info(); + let tab_viewport = tab.get_viewport(); + let tab_display_area = tab.get_display_area(); + let selectable_tiled_panes_count = tab.get_selectable_tiled_panes_count(); + let selectable_floating_panes_count = tab.get_selectable_floating_panes_count(); + let tab_info_for_screen = TabInfo { + position: tab.position, + name: tab.name.clone(), + active: self.active_tab_ids.values().any(|i| i == &tab.id), + panes_to_hide: tab.panes_to_hide_count(), + is_fullscreen_active: tab.is_fullscreen_active(), + is_sync_panes_active: tab.is_sync_panes_active(), + are_floating_panes_visible: tab.are_floating_panes_visible(), + other_focused_clients: all_focused_clients, + active_swap_layout_name, + is_swap_layout_dirty, + viewport_rows: tab_viewport.rows, + viewport_columns: tab_viewport.cols, + display_area_rows: tab_display_area.rows, + display_area_columns: tab_display_area.cols, + selectable_tiled_panes_count, + selectable_floating_panes_count, + tab_id: tab.id, + has_bell_notification: tab.tab_has_pending_bell + && !self.active_tab_ids.values().any(|i| i == &tab.id), + is_flashing_bell: tab.tab_bell_flash + && !self.active_tab_ids.values().any(|i| i == &tab.id), + }; + tab_infos_for_screen_state.insert(tab.position, tab_info_for_screen); + } + for (client_id, active_tab_index) in self.active_tab_ids.iter() { + let mut plugin_tab_updates = vec![]; + for tab in self.tabs.values() { + if let Some(set) = &tab.visible_to { + if !set.contains(client_id) { + continue; + } + } + let other_focused_clients: Vec<ClientId> = if self.session_is_mirrored { + vec![] + } else { + let mut clients: Vec<ClientId> = self + .active_tab_ids + .iter() + .filter(|(c_id, tab_position)| { + **tab_position == tab.id && *c_id != client_id + }) + .map(|(c_id, _)| c_id) + .copied() + .collect(); + for s_id in tab.shadow_focus_clients() { + if s_id != *client_id && !clients.contains(&s_id) { + clients.push(s_id); + } + } + clients + }; + let (active_swap_layout_name, is_swap_layout_dirty) = tab.swap_layout_info(); + let tab_viewport = tab.get_viewport(); + let tab_display_area = tab.get_display_area(); + let selectable_tiled_panes_count = tab.get_selectable_tiled_panes_count(); + let selectable_floating_panes_count = tab.get_selectable_floating_panes_count(); + let tab_info_for_plugins = TabInfo { + position: tab.position, + name: tab + .single_pane_tab_name() + .unwrap_or_else(|| tab.name.clone()), + active: *active_tab_index == tab.id, + panes_to_hide: tab.panes_to_hide_count(), + is_fullscreen_active: tab.is_fullscreen_active(), + is_sync_panes_active: tab.is_sync_panes_active(), + are_floating_panes_visible: tab.are_floating_panes_visible(), + other_focused_clients, + active_swap_layout_name, + is_swap_layout_dirty, + viewport_rows: tab_viewport.rows, + viewport_columns: tab_viewport.cols, + display_area_rows: tab_display_area.rows, + display_area_columns: tab_display_area.cols, + selectable_tiled_panes_count, + selectable_floating_panes_count, + tab_id: tab.id, + has_bell_notification: tab.tab_has_pending_bell && *active_tab_index != tab.id, + is_flashing_bell: tab.tab_bell_flash && *active_tab_index != tab.id, + }; + plugin_tab_updates.push(tab_info_for_plugins); + } + plugin_tab_updates.sort_by(|a, b| a.position.cmp(&b.position)); + let target_plugin_ids = self.targeted_plugin_ids(*client_id, EventType::TabUpdate); + for plugin_id in target_plugin_ids { + plugin_updates.push(( + Some(plugin_id), + Some(*client_id), + Event::TabUpdate(plugin_tab_updates.clone()), + )); + } + } + self.bus + .senders + .send_to_plugin(PluginInstruction::Update(plugin_updates)) + .context("failed to update tabs")?; + Ok(tab_infos_for_screen_state.values().cloned().collect()) + } + fn generate_and_report_pane_state(&mut self) -> Result<PaneManifest> { + let mut pane_manifest = PaneManifest::default(); + for tab in self.tabs.values() { + pane_manifest.panes.insert(tab.position, tab.pane_infos()); + } + let mut plugin_updates = vec![]; + let client_ids: Vec<ClientId> = self.active_tab_ids.keys().copied().collect(); + for client_id in client_ids { + let target_plugin_ids = self.targeted_plugin_ids(client_id, EventType::PaneUpdate); + for plugin_id in target_plugin_ids { + plugin_updates.push(( + Some(plugin_id), + Some(client_id), + Event::PaneUpdate(pane_manifest.clone()), + )); + } + } + if !plugin_updates.is_empty() { + self.bus + .senders + .send_to_plugin(PluginInstruction::Update(plugin_updates)) + .context("failed to update pane state")?; + } + + Ok(pane_manifest) + } + + fn collect_pane_list(&self, show_all: bool) -> Result<ListPanesResponse> { + fn should_include_pane(pane_info: &PaneInfo, show_all: bool) -> bool { + pane_info.is_selectable || show_all + } + + fn create_pane_list_entry(pane_info: PaneInfo, tab: &crate::tab::Tab) -> PaneListEntry { + PaneListEntry { + pane_info, + tab_id: tab.id, + tab_position: tab.position, + tab_name: tab.name.clone(), + pane_command: None, + pane_cwd: None, + } + } + + fn sort_panes_by_tab_and_type(pane_entries: &mut [PaneListEntry]) { + pane_entries.sort_by_key(|e| (e.tab_position, !e.pane_info.is_plugin, e.pane_info.id)); + } + + let mut pane_entries = Vec::new(); + + for tab in self.tabs.values() { + let pane_infos = tab.pane_infos(); + + for pane_info in pane_infos { + if should_include_pane(&pane_info, show_all) { + pane_entries.push(create_pane_list_entry(pane_info, tab)); + } + } + } + + sort_panes_by_tab_and_type(&mut pane_entries); + Ok(pane_entries) + } + + fn collect_tab_list(&self, _client_id: ClientId) -> Result<ListTabsResponse> { + let mut tab_infos = Vec::new(); + + for tab in self.tabs.values() { + if let Some(tab_info) = self.get_tab_info(tab.id) { + tab_infos.push(tab_info); + } + } + + // Sort by position (display order) + tab_infos.sort_by_key(|t| t.position); + + Ok(tab_infos) + } + + fn get_current_tab_info(&self, client_id: ClientId) -> Result<Option<TabInfo>> { + match self.active_tab_ids.get(&client_id) { + Some(active_tab_id) => Ok(self.get_tab_info(*active_tab_id)), + None => Ok(None), + } + } + + fn update_single_pane_tab_names(&mut self) -> bool { + let current: HashMap<usize, Option<String>> = self + .tabs + .values() + .map(|tab| (tab.id, tab.single_pane_tab_name())) + .collect(); + if current != self.last_single_pane_tab_names { + self.last_single_pane_tab_names = current; + true + } else { + false + } + } + fn log_and_report_session_state(&mut self) -> Result<()> { + let err_context = || format!("Failed to log and report session state"); + + self.update_active_pane_ids(); + // generate own session info + let pane_manifest = self.generate_and_report_pane_state()?; + let tab_infos = self.generate_and_report_tab_state()?; + + // Lazy-load layouts on first call if cache is empty + // After that, cache is updated by watcher via UpdateAvailableLayouts instruction + if self.cached_layouts.is_empty() { + #[cfg(not(test))] + { + let (layouts, errors) = Layout::list_available_layouts( + self.layout_dir.clone(), + &self.default_layout_name, + ); + self.cached_layouts = layouts; + self.cached_layout_errors = errors; + } + #[cfg(test)] + { + self.cached_layouts = vec![]; + self.cached_layout_errors = vec![]; + } + } + let available_layouts = self.cached_layouts.clone(); + let creation_time = { + let sock_path = ZELLIJ_SOCK_DIR.join(&self.session_name); + std::fs::metadata(&sock_path) + .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() + }; + let session_info = SessionInfo { + name: self.session_name.clone(), + tabs: tab_infos, + panes: pane_manifest, + connected_clients: self.active_tab_ids.keys().len(), + is_current_session: true, + available_layouts, + web_clients_allowed: self.web_sharing.web_clients_allowed(), + web_client_count: self + .connected_clients + .borrow() + .iter() + .filter(|(_client_id, is_web_client)| **is_web_client) + .count(), + plugins: Default::default(), // these are filled in by the wasm thread + tab_history: self.tab_history.clone(), + pane_history: self + .pane_history + .iter() + .map(|(k, v)| (*k, v.iter().map(|v| (*v).into()).collect())) + .collect(), + creation_time, + }; + self.bus + .senders + .send_to_background_jobs(BackgroundJob::ReportSessionInfo( + self.session_name.to_owned(), + 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_plugin(PluginInstruction::Update(vec![( + None, + None, + Event::SessionUpdate(live_sessions, resurrectable_sessions), + )])) + .with_context(err_context)?; + + self.bus + .senders + .send_to_background_jobs(BackgroundJob::QueryZellijWebServerStatus) + .with_context(err_context)?; + Ok(()) + } + fn dump_layout_to_hd(&mut self) -> Result<()> { + let err_context = || format!("Failed to log and report session state"); + let session_layout_metadata = + self.get_layout_metadata(Some(self.default_shell.clone()), None); + self.bus + .senders + .send_to_plugin(PluginInstruction::LogLayoutToHd(session_layout_metadata)) + .with_context(err_context)?; + + Ok(()) + } + pub fn update_session_infos( + &mut self, + new_session_infos: BTreeMap<String, SessionInfo>, + resurrectable_sessions: BTreeMap<String, Duration>, + ) -> Result<()> { + 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.peer_sessions_cache.values().cloned().collect(), + self.resurrectable_sessions_cache + .iter() + .map(|(n, c)| (n.clone(), c.clone())) + .collect(), + ), + )])) + .context("failed to update session info")?; + Ok(()) + } + + pub fn update_available_layouts( + &mut self, + layouts: Vec<LayoutInfo>, + errors: Vec<LayoutWithError>, + ) { + self.cached_layouts = layouts; + self.cached_layout_errors = errors; + } + + pub fn update_active_tab_name(&mut self, buf: Vec<u8>, client_id: ClientId) -> Result<()> { + let err_context = + || format!("failed to update active tabs name for client id: {client_id:?}"); + + let client_id = if self.get_active_tab(client_id).is_ok() { + Some(client_id) + } else { + self.get_first_client_id() + }; + + match client_id { + Some(client_id) => { + let s = str::from_utf8(&buf) + .with_context(|| format!("failed to construct tab name from buf: {buf:?}")) + .with_context(err_context)?; + match self.get_active_tab_mut(client_id) { + Ok(active_tab) => { + match s { + "\0" => { + active_tab.name = String::new(); + }, + "\u{007F}" | "\u{0008}" => { + // delete and backspace keys + active_tab.name.pop(); + }, + c => { + active_tab + .name + .push_str(&clean_string_from_control_and_linebreak(c)); + }, + } + self.log_and_report_session_state() + .with_context(err_context) + }, + Err(err) => { + Err::<(), _>(err).with_context(err_context).non_fatal(); + Ok(()) + }, + } + }, + None => Ok(()), + } + } + pub fn undo_active_rename_tab(&mut self, client_id: ClientId) -> Result<()> { + let err_context = || format!("failed to undo active tab rename for client {}", client_id); + + let client_id = if self.get_active_tab(client_id).is_ok() { + Some(client_id) + } else { + self.get_first_client_id() + }; + match client_id { + Some(client_id) => { + match self.get_active_tab_mut(client_id) { + Ok(active_tab) => { + if active_tab.name != active_tab.prev_name { + active_tab.name = active_tab.prev_name.clone(); + self.log_and_report_session_state() + .context("failed to undo renaming of active tab")?; + } + }, + Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(), + }; + Ok(()) + }, + None => Ok(()), + } + } + + pub fn move_active_tab_to_left(&mut self, client_id: ClientId) -> Result<()> { + let err_context = || "Failed to move active tab left"; + if self.tabs.len() < 2 { + debug!("cannot move tab to left: only one tab exists"); + return Ok(()); + } + let Some(client_id) = self.client_id(client_id) else { + return Ok(()); + }; + + match self.get_active_tab(client_id) { + Ok(active_tab) => { + let active_tab_pos = active_tab.position; + if active_tab_pos == 0 { + self.rotate_tab_positions_left(); + } else { + self.switch_tabs(active_tab_pos, active_tab_pos.saturating_sub(1)); + } + self.log_and_report_session_state() + .context("failed to move tab to left")?; + }, + Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(), + } + Ok(()) + } + + fn client_id(&mut self, client_id: ClientId) -> Option<u16> { + if self.get_active_tab(client_id).is_ok() { + Some(client_id) + } else { + self.get_first_client_id() + } + } + + /// Switches tabs at two positions, swapping their display order. + /// + /// # Arguments + /// * `active_tab_pos` - Current position of active tab (0-based) + /// * `other_tab_pos` - Position to swap with (0-based) + /// + /// NOTE: this expects positions rather than IDs (see distinction at top of file) + fn switch_tabs(&mut self, active_tab_pos: usize, other_tab_pos: usize) { + let Some(active_tab_id) = self + .tabs + .values() + .find(|t| t.position == active_tab_pos) + .map(|t| t.id) + else { + log::error!("Failed to find active tab at position: {}", active_tab_pos); + return; + }; + let Some(other_tab_id) = self + .tabs + .values() + .find(|t| t.position == other_tab_pos) + .map(|t| t.id) + else { + log::error!( + "Failed to find tab to switch to at position: {}", + other_tab_pos + ); + return; + }; + + if !self.tabs.contains_key(&active_tab_id) || !self.tabs.contains_key(&other_tab_id) { + warn!( + "failed to switch tabs: index {} or {} not found in {:?}", + active_tab_id, + other_tab_id, + self.tabs.keys() + ); + return; + } + + // NOTE: Can `expect` here, because we checked that the keys exist above + let mut active_tab = self + .tabs + .remove(&active_tab_id) + .expect("active tab not found"); + let mut other_tab = self + .tabs + .remove(&other_tab_id) + .expect("other tab not found"); + + std::mem::swap(&mut active_tab.position, &mut other_tab.position); + + self.tabs.insert(active_tab_id, active_tab); + self.tabs.insert(other_tab_id, other_tab); + } + + fn rotate_tab_positions_left(&mut self) { + let last_position = self.tabs.len().saturating_sub(1); + for tab in self.tabs.values_mut() { + tab.position = if tab.position == 0 { + last_position + } else { + tab.position.saturating_sub(1) + }; + } + } + + fn rotate_tab_positions_right(&mut self) { + let num_tabs = self.tabs.len(); + for tab in self.tabs.values_mut() { + tab.position = (tab.position + 1) % num_tabs; + } + } + + pub fn move_active_tab_to_right(&mut self, client_id: ClientId) -> Result<()> { + let err_context = || "Failed to move active tab right "; + if self.tabs.len() < 2 { + debug!("cannot move tab to right: only one tab exists"); + return Ok(()); + } + let Some(client_id) = self.client_id(client_id) else { + return Ok(()); + }; + + match self.get_active_tab(client_id) { + Ok(active_tab) => { + let active_tab_pos = active_tab.position; + if active_tab_pos == self.tabs.len().saturating_sub(1) { + self.rotate_tab_positions_right(); + } else { + self.switch_tabs(active_tab_pos, active_tab_pos + 1); + } + self.log_and_report_session_state() + .context("failed to move tab to the right")?; + }, + Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(), + } + Ok(()) + } + + pub fn move_tab_by_id(&mut self, tab_id: usize, direction: Direction) -> Result<()> { + if self.tabs.len() < 2 { + return Ok(()); + } + if let Some(tab) = self.tabs.get(&tab_id) { + let tab_pos = tab.position; + let num_tabs = self.tabs.len(); + match direction { + Direction::Left => { + if tab_pos == 0 { + self.rotate_tab_positions_left(); + } else { + self.switch_tabs(tab_pos, tab_pos.saturating_sub(1)); + } + }, + Direction::Right => { + if tab_pos == num_tabs.saturating_sub(1) { + self.rotate_tab_positions_right(); + } else { + self.switch_tabs(tab_pos, tab_pos + 1); + } + }, + _ => return Ok(()), + } + self.log_and_report_session_state()?; + } else { + log::error!("Tab with id {} not found", tab_id); + } + Ok(()) + } + + pub fn change_mode( + &mut self, + new_mode: InputMode, + base_mode: Option<InputMode>, + client_id: ClientId, + ) -> Result<()> { + let mut mode_info = self + .mode_info + .get(&client_id) + .cloned() + .unwrap_or_else(|| self.default_mode_info.clone()); + let previous_mode = mode_info.mode; + mode_info.mode = new_mode; + mode_info.base_mode = base_mode; + if mode_info.session_name.as_ref() != Some(&self.session_name) { + mode_info.session_name = Some(self.session_name.clone()); + } + + let err_context = || { + format!( + "failed to change from mode '{:?}' to mode '{:?}' for client {client_id}", + previous_mode, new_mode + ) + }; + + // If we leave the Search-related modes, we need to clear all previous searches + let search_related_modes = [InputMode::EnterSearch, InputMode::Search, InputMode::Scroll]; + if search_related_modes.contains(&previous_mode) + && !search_related_modes.contains(&mode_info.mode) + { + active_tab!(self, client_id, |tab: &mut Tab| tab.clear_search(client_id)); + } + + if previous_mode == InputMode::Scroll + && (mode_info.mode == InputMode::Normal || mode_info.mode == InputMode::Locked) + { + if let Ok(active_tab) = self.get_active_tab_mut(client_id) { + active_tab + .clear_active_terminal_scroll(client_id) + .with_context(err_context)?; + } + } + + if mode_info.mode == InputMode::RenameTab { + if let Ok(active_tab) = self.get_active_tab_mut(client_id) { + active_tab.prev_name = active_tab.name.clone(); + } + } + + if mode_info.mode == InputMode::RenamePane { + if let Ok(active_tab) = self.get_active_tab_mut(client_id) { + if let Some(active_pane) = + active_tab.get_active_pane_or_floating_pane_mut(client_id) + { + active_pane.store_pane_name(); + } + } + } + + self.style = mode_info.style; + self.mode_info.insert(client_id, mode_info.clone()); + for tab in self.tabs.values_mut() { + tab.change_mode_info(mode_info.clone(), client_id); + tab.mark_active_pane_for_rerender(client_id); + tab.update_input_modes()?; + } + // Notify background plugins subscribed to ModeUpdate + let mut bg_updates = vec![]; + for ((bg_pid, bg_cid), subs) in &self.background_plugin_subscriptions { + if subs.contains(&EventType::ModeUpdate) && *bg_cid == client_id { + bg_updates.push(( + Some(*bg_pid), + Some(*bg_cid), + Event::ModeUpdate(mode_info.clone()), + )); + } + } + if !bg_updates.is_empty() { + self.bus + .senders + .send_to_plugin(PluginInstruction::Update(bg_updates)) + .context("failed to update background plugins with mode info")?; + } + Ok(()) + } + pub fn change_mode_for_all_clients( + &mut self, + new_mode: InputMode, + base_mode: Option<InputMode>, + ) -> Result<()> { + let err_context = || { + format!( + "failed to change input mode to {:?} for all clients", + new_mode + ) + }; + + let connected_client_ids: Vec<ClientId> = self.active_tab_ids.keys().copied().collect(); + for client_id in connected_client_ids { + self.change_mode(new_mode, base_mode, client_id) + .with_context(err_context)?; + } + Ok(()) + } + /// Collect plugin IDs that should receive a broadcast event for a given client. + /// Returns plugin IDs from the client's active tab plus background plugins + /// subscribed to the given event type. + fn targeted_plugin_ids(&self, client_id: ClientId, event_type: EventType) -> Vec<PluginId> { + let mut plugin_ids = Vec::new(); + // Active-tab plugins + if let Some(active_tab_id) = self.active_tab_ids.get(&client_id) { + if let Some(tab) = self.tabs.get(active_tab_id) { + plugin_ids.extend(tab.get_plugin_ids()); + } + } + // Background plugins subscribed to this event type + for ((bg_pid, bg_cid), subs) in &self.background_plugin_subscriptions { + if subs.contains(&event_type) && *bg_cid == client_id { + if !plugin_ids.contains(bg_pid) { + plugin_ids.push(*bg_pid); + } + } + } + plugin_ids + } + /// Broadcast a ModeUpdate event to active-tab plugins and subscribed background plugins. + pub fn broadcast_mode_update( + &mut self, + mode_info: ModeInfo, + target_client_id: Option<ClientId>, + ) -> Result<()> { + let mut plugin_updates = vec![]; + let client_ids: Vec<ClientId> = if let Some(cid) = target_client_id { + vec![cid] + } else { + self.active_tab_ids.keys().copied().collect() + }; + for client_id in client_ids { + let plugin_ids = self.targeted_plugin_ids(client_id, EventType::ModeUpdate); + for plugin_id in plugin_ids { + plugin_updates.push(( + Some(plugin_id), + Some(client_id), + Event::ModeUpdate(mode_info.clone()), + )); + } + } + if !plugin_updates.is_empty() { + self.bus + .senders + .send_to_plugin(PluginInstruction::Update(plugin_updates)) + .context("failed to broadcast mode update")?; + } + Ok(()) + } + pub fn move_focus_left_or_previous_tab(&mut self, client_id: ClientId) -> Result<()> { + let err_context = || { + format!( + "failed to move focus left or to previous tab for client {}", + client_id + ) + }; + + let client_id = if self.get_active_tab(client_id).is_ok() { + Some(client_id) + } else { + self.get_first_client_id() + }; + if let Some(client_id) = client_id { + match self.get_active_tab_mut(client_id) { + Ok(active_tab) => { + active_tab + .move_focus_left(client_id) + .and_then(|success| { + if !success { + if self.tabs.len() == 1 { + self.bubble_focus_to_host(client_id, Direction::Left); + Ok(()) + } else { + self.switch_tab_prev(Some(Direction::Left), true, client_id) + .context("failed to move focus to previous tab") + } + } else { + Ok(()) + } + }) + .with_context(err_context)?; + }, + Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(), + }; + } + self.log_and_report_session_state() + .with_context(err_context)?; + Ok(()) + } + pub fn move_focus_right_or_next_tab(&mut self, client_id: ClientId) -> Result<()> { + let err_context = || { + format!( + "failed to move focus right or to next tab for client {}", + client_id + ) + }; + + let client_id = if self.get_active_tab(client_id).is_ok() { + Some(client_id) + } else { + self.get_first_client_id() + }; + + if let Some(client_id) = client_id { + match self.get_active_tab_mut(client_id) { + Ok(active_tab) => { + active_tab + .move_focus_right(client_id) + .and_then(|success| { + if !success { + if self.tabs.len() == 1 { + self.bubble_focus_to_host(client_id, Direction::Right); + Ok(()) + } else { + self.switch_tab_next(Some(Direction::Right), true, client_id) + .context("failed to move focus to next tab") + } + } else { + Ok(()) + } + }) + .with_context(err_context)?; + }, + Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(), + }; + } + self.log_and_report_session_state() + .with_context(err_context)?; + Ok(()) + } + pub fn toggle_tab(&mut self, client_id: ClientId) -> Result<()> { + let tab = self + .get_previous_tab(client_id) + .context("failed to toggle tabs")?; + if let Some(t) = tab { + let position = t.position; + self.go_to_tab(position + 1, client_id) + .context("failed to toggle tabs")?; + }; + + self.log_and_report_session_state() + .context("failed to toggle tabs")?; + self.render(None) + } + + pub fn focus_plugin_pane( + &mut self, + run_plugin: &RunPluginOrAlias, + should_float: bool, + move_to_focused_tab: bool, + should_be_in_place: bool, + client_id: ClientId, + completion_tx: &mut Option<NotificationEnd>, + ) -> Result<bool> { + // true => found and focused, false => not + let err_context = || format!("failed to focus_plugin_pane"); + let mut tab_index_and_plugin_pane_id = None; + let mut plugin_pane_to_move_to_active_tab = None; + let focused_tab_index = *self.active_tab_ids.get(&client_id).unwrap_or(&0); + let all_tabs = self.get_tabs_mut(); + for (tab_index, tab) in all_tabs.iter_mut() { + if let Some(plugin_pane_id) = tab.find_plugin(&run_plugin) { + tab_index_and_plugin_pane_id = Some((*tab_index, plugin_pane_id)); + if move_to_focused_tab && focused_tab_index != *tab_index { + plugin_pane_to_move_to_active_tab = tab.extract_pane(plugin_pane_id, true); + } + + break; + } + } + if let Some(plugin_pane_to_move_to_active_tab) = plugin_pane_to_move_to_active_tab.take() { + let pane_id = plugin_pane_to_move_to_active_tab.pid(); + let new_active_tab = self.get_active_tab_mut(client_id)?; + + if should_float { + new_active_tab.show_floating_panes(); + new_active_tab.add_floating_pane( + plugin_pane_to_move_to_active_tab, + pane_id, + None, + true, + Some(client_id), + )?; + // TODO: also should_be_in_place + } else { + new_active_tab.hide_floating_panes(); + new_active_tab.add_tiled_pane( + plugin_pane_to_move_to_active_tab, + pane_id, + false, + Some(client_id), + )?; + } + // Set affected pane ID for CLI client output + if let Some(ref mut completion) = completion_tx { + completion.set_affected_pane_id(pane_id); + } + return Ok(true); + } + match tab_index_and_plugin_pane_id { + Some((tab_index, plugin_pane_id)) => { + self.go_to_tab(tab_index + 1, client_id)?; + self.tabs + .get_mut(&tab_index) + .with_context(err_context)? + .focus_pane_with_id(plugin_pane_id, should_float, should_be_in_place, client_id) + .context("failed to focus plugin pane")?; + self.log_and_report_session_state() + .with_context(err_context)?; + // Set affected pane ID for CLI client output + if let Some(ref mut completion) = completion_tx { + completion.set_affected_pane_id(plugin_pane_id); + } + Ok(true) + }, + None => Ok(false), + } + } + + pub fn focus_pane_with_id( + &mut self, + pane_id: PaneId, + should_float_if_hidden: bool, + should_open_in_place: bool, + client_id: ClientId, + ) -> Result<()> { + let err_context = || format!("failed to focus_plugin_pane"); + let tab_index = self + .tabs + .iter() + .find(|(_tab_index, tab)| tab.has_pane_with_pid(&pane_id)) + .map(|(_tab_index, tab)| tab.position); + match tab_index { + Some(tab_index) => { + self.go_to_tab(tab_index + 1, client_id)?; + self.tabs + .iter_mut() + .find(|(_, t)| t.position == tab_index) + .map(|(_, t)| { + t.focus_pane_with_id( + pane_id, + should_float_if_hidden, + should_open_in_place, + client_id, + ) + }) + .with_context(err_context) + .non_fatal(); + }, + None => { + log::error!("Could not find pane with id: {:?}", pane_id); + }, + }; + Ok(()) + } + pub fn rerun_command_pane_with_id( + &mut self, + terminal_pane_id: u32, + completion_tx: Option<NotificationEnd>, + ) { + let mut found = false; + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&PaneId::Terminal(terminal_pane_id)) { + tab.rerun_terminal_pane_with_id(terminal_pane_id, completion_tx); + found = true; + break; + } + } + if !found { + log::error!( + "Failed to find terminal pane with id: {} to run", + terminal_pane_id + ); + } + } + pub fn resize_pane_with_id(&mut self, resize: ResizeStrategy, pane_id: PaneId) { + let mut found = false; + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.resize_pane_with_id(resize, pane_id).non_fatal(); + found = true; + break; + } + } + if !found { + log::error!("Failed to find pane with id: {:?} to resize", pane_id); + } + } + pub fn break_pane( + &mut self, + default_shell: Option<TerminalAction>, + default_layout: Box<Layout>, + client_id: ClientId, + ) -> Result<()> { + let err_context = || "failed break pane out of tab".to_string(); + let active_tab = self.get_active_tab_mut(client_id)?; + let active_pane_id = active_tab + .get_active_pane_id(client_id) + .with_context(err_context)?; + if active_tab.get_selectable_tiled_panes_count() > 1 + || active_tab.get_visible_selectable_floating_panes_count() > 0 + || active_tab.pane_is_stack_list_member(&active_pane_id) + { + let active_pane = active_tab + .extract_pane(active_pane_id, false) + .with_context(err_context)?; + let active_pane_run_instruction = active_pane.invoked_with().clone(); + let tab_index = self.get_new_tab_id(); + let swap_layouts = ( + default_layout.swap_tiled_layouts.clone(), + default_layout.swap_floating_layouts.clone(), + ); + self.new_tab(tab_index, swap_layouts, None, Some(client_id))?; + let tab = self.tabs.get_mut(&tab_index).with_context(err_context)?; + let (mut tiled_panes_layout, floating_panes_layout) = default_layout.new_tab(); + let without_relayout = true; + tab.add_tiled_pane( + active_pane, + active_pane_id, + without_relayout, + Some(client_id), + )?; + tiled_panes_layout.ignore_run_instruction(active_pane_run_instruction.clone()); + let should_change_focus_to_new_tab = true; + let is_web_client = self + .connected_clients + .borrow() + .get(&client_id) + .copied() + .unwrap_or(false); + self.bus.senders.send_to_plugin(PluginInstruction::NewTab( + None, + default_shell, + Some(tiled_panes_layout), + floating_panes_layout, + tab_index, + None, // initial_panes + false, // block_on_first_terminal + should_change_focus_to_new_tab, + (client_id, is_web_client), + None, + ))?; + } else { + self.bus + .senders + .send_to_background_jobs(BackgroundJob::DisplayPaneError( + vec![active_pane_id], + "Cannot break single pane out!".into(), + )) + .with_context(err_context)?; + } + Ok(()) + } + pub fn break_multiple_panes_to_new_tab( + &mut self, + pane_ids: Vec<PaneId>, + default_shell: Option<TerminalAction>, + should_change_focus_to_new_tab: bool, + new_tab_name: Option<String>, + client_id: ClientId, + ) -> Result<usize> { + let err_context = || "failed break multiple panes to a new tab".to_string(); + + let all_tabs = self.get_tabs_mut(); + let mut extracted_panes = vec![]; + for pane_id in pane_ids { + for tab in all_tabs.values_mut() { + // here we pass None instead of the client_id we have because we do not need to + // necessarily trigger a relayout for this tab + if let Some(pane) = tab.extract_pane(pane_id, true).take() { + extracted_panes.push(pane); + break; + } + } + } + + let (mut tiled_panes_layout, floating_panes_layout) = self.default_layout.new_tab(); + let tab_index = self.get_new_tab_id(); + let swap_layouts = ( + self.default_layout.swap_tiled_layouts.clone(), + self.default_layout.swap_floating_layouts.clone(), + ); + if should_change_focus_to_new_tab { + self.new_tab(tab_index, swap_layouts, None, Some(client_id))?; + } else { + self.new_tab(tab_index, swap_layouts, None, None)?; + } + let tab = self.tabs.get_mut(&tab_index).with_context(err_context)?; + if let Some(new_tab_name) = new_tab_name { + tab.name = new_tab_name.clone(); + } + for mut pane in extracted_panes { + let run_instruction = pane.invoked_with().clone(); + let pane_id = pane.pid(); + let without_relayout = true; + + // we reset the pane geom here to screen size so that we won't have trouble adding it + // temporarily to the new tab (eg. if it was stacked or had a fixed size), the size + // will be adjusted before the next render, further down the pipeline, when we apply + // the layout to this new tab + let new_geom = PaneGeom::from(&self.size); + pane.set_geom(new_geom); + + // here we pass None instead of the ClientId, because we do not want this pane to be + // necessarily focused + tab.add_tiled_pane(pane, pane_id, without_relayout, None)?; + tiled_panes_layout.ignore_run_instruction(run_instruction.clone()); + } + let is_web_client = self + .connected_clients + .borrow() + .get(&client_id) + .copied() + .unwrap_or(false); + self.bus.senders.send_to_plugin(PluginInstruction::NewTab( + None, + default_shell, + Some(tiled_panes_layout), + floating_panes_layout, + tab_index, + None, // initial_panes + false, // block_on_first_terminal + should_change_focus_to_new_tab, + (client_id, is_web_client), + None, + ))?; + Ok(tab_index) + } + pub fn break_pane_to_new_tab( + &mut self, + direction: Direction, + client_id: ClientId, + ) -> Result<()> { + let err_context = || "failed break pane out of tab".to_string(); + if self.tabs.len() > 1 { + let (active_pane_id, active_pane, pane_to_break_is_floating) = { + let active_tab = self.get_active_tab_mut(client_id)?; + let active_pane_id = active_tab + .get_active_pane_id(client_id) + .with_context(err_context)?; + let pane_to_break_is_floating = active_tab.are_floating_panes_visible(); + let active_pane = active_tab + .extract_pane(active_pane_id, false) + .with_context(err_context)?; + (active_pane_id, active_pane, pane_to_break_is_floating) + }; + let update_mode_infos = false; + match direction { + Direction::Right | Direction::Down => { + self.switch_tab_next(None, update_mode_infos, client_id)?; + }, + Direction::Left | Direction::Up => { + self.switch_tab_prev(None, update_mode_infos, client_id)?; + }, + }; + let new_active_tab = self.get_active_tab_mut(client_id)?; + + if pane_to_break_is_floating { + new_active_tab.show_floating_panes(); + new_active_tab.add_floating_pane( + active_pane, + active_pane_id, + None, + true, + Some(client_id), + )?; + } else { + new_active_tab.hide_floating_panes(); + new_active_tab.add_tiled_pane( + active_pane, + active_pane_id, + false, + Some(client_id), + )?; + } + + self.log_and_report_session_state()?; + } else { + let active_pane_id = { + let active_tab = self.get_active_tab_mut(client_id)?; + active_tab + .get_active_pane_id(client_id) + .with_context(err_context)? + }; + self.bus + .senders + .send_to_background_jobs(BackgroundJob::DisplayPaneError( + vec![active_pane_id], + "No other tabs to add pane to!".into(), + )) + .with_context(err_context)?; + } + self.render(None)?; + Ok(()) + } + pub fn break_multiple_panes_to_tab_with_index( + &mut self, + pane_ids: Vec<PaneId>, + tab_index: usize, + should_change_focus_to_new_tab: bool, + client_id: ClientId, + ) -> Result<()> { + let all_tabs = self.get_tabs_mut(); + let has_tab_with_index = all_tabs + .values() + .find(|t| t.position == tab_index) + .is_some(); + if !has_tab_with_index { + log::error!("Cannot find tab with index: {tab_index}"); + return Ok(()); + } + let mut extracted_panes = vec![]; + for pane_id in pane_ids { + for tab in all_tabs.values_mut() { + if tab.position == tab_index { + continue; + } + // here we pass None instead of the client_id we have because we do not need to + // necessarily trigger a relayout for this tab + let pane_was_floating = tab.pane_id_is_floating(&pane_id); + if let Some(pane) = tab.extract_pane(pane_id, true).take() { + extracted_panes.push((pane_was_floating, pane)); + break; + } + } + } + + if should_change_focus_to_new_tab { + self.go_to_tab(tab_index + 1, client_id)?; + } + if extracted_panes.is_empty() { + // nothing to do here... + return Ok(()); + } + let screen_size = self.size; + if let Some(new_active_tab) = self.get_indexed_tab_mut(tab_index) { + for (pane_was_floating, mut pane) in extracted_panes { + let pane_id = pane.pid(); + if pane_was_floating { + let floating_pane_coordinates = FloatingPaneCoordinates { + x: Some(PercentOrFixed::Fixed(pane.x())), + y: Some(PercentOrFixed::Fixed(pane.y())), + width: Some(PercentOrFixed::Fixed(pane.cols())), + height: Some(PercentOrFixed::Fixed(pane.rows())), + pinned: Some(pane.current_geom().is_pinned), + borderless: Some(pane.borderless()), + }; + new_active_tab.add_floating_pane( + pane, + pane_id, + Some(floating_pane_coordinates), + false, + Some(client_id), + )?; + } else { + // here we pass None instead of the ClientId, because we do not want this pane to be + // necessarily focused + + // we reset the pane geom here to screen size so that we won't have trouble adding it + // temporarily to the new tab (eg. if it was stacked or had a fixed size), the size + // will be adjusted before the next render, further down the pipeline, when we apply + // the layout to this new tab + let new_geom = PaneGeom::from(&screen_size); + pane.set_geom(new_geom); + + new_active_tab.add_tiled_pane(pane, pane_id, false, None)?; + } + } + } else { + log::error!("Could not find tab with index: {:?}", tab_index); + } + self.log_and_report_session_state()?; + Ok(()) + } + pub fn replace_pane( + &mut self, + new_pane_id: PaneId, + hold_for_command: HoldForCommand, + run: Option<Run>, + pane_title: Option<InitialTitle>, + close_replaced_pane: bool, + client_id_tab_index_or_pane_id: ClientTabIndexOrPaneId, + ) -> Result<()> { + let suppress_pane = |tab: &mut Tab, pane_id: PaneId, new_pane_id: PaneId| { + let _ = tab.suppress_pane_and_replace_with_pid( + pane_id, + new_pane_id, + close_replaced_pane, + run, + None, + None, + ); + if let Some(pane_title) = pane_title { + let _ = tab.rename_pane(pane_title.as_bytes().to_vec(), new_pane_id); + } + if let Some(hold_for_command) = hold_for_command { + let is_first_run = true; + tab.hold_pane(new_pane_id, None, is_first_run, hold_for_command) + } + }; + match client_id_tab_index_or_pane_id { + ClientTabIndexOrPaneId::ClientId(client_id) + | ClientTabIndexOrPaneId::ClientIdNoFocus(client_id) => { + active_tab!(self, client_id, |tab: &mut Tab| { + match tab.get_active_pane_id(client_id) { + Some(pane_id) => { + suppress_pane(tab, pane_id, new_pane_id); + }, + None => { + log::error!( + "Failed to find active pane for client id: {:?}", + client_id + ); + }, + } + }); + }, + ClientTabIndexOrPaneId::PaneId(pane_id) => { + let tab_index = self + .tabs + .iter() + .find(|(_tab_index, tab)| tab.has_pane_with_pid(&pane_id)) + .map(|(_tab_index, tab)| tab.position); + match tab_index { + Some(tab_index) => { + if let Some(tab) = + self.tabs.iter_mut().find(|(_, t)| t.position == tab_index) + { + suppress_pane(tab.1, pane_id, new_pane_id); + } + }, + None => { + log::error!("Could not find pane with id: {:?}", pane_id); + }, + }; + }, + ClientTabIndexOrPaneId::TabIndex(_tab_index) + | ClientTabIndexOrPaneId::TabIndexNoFocus(_tab_index) => { + log::error!("Cannot replace pane with tab index"); + }, + } + Ok(()) + } + pub fn replace_pane_with_existing_pane( + &mut self, + pane_id_to_replace: PaneId, + pane_id_of_existing_pane: PaneId, + suppress_replaced_pane: bool, + _completion_tx: Option<NotificationEnd>, // ends here + ) { + let Some(tab_index_of_pane_id_to_replace) = self + .tabs + .iter() + .find(|(_tab_index, tab)| tab.has_pane_with_pid(&pane_id_to_replace)) + .map(|(_tab_index, tab)| tab.position) + else { + log::error!( + "Could not find tab with pane_id: {:?} to replace", + pane_id_to_replace + ); + return; + }; + let Some(tab_index_of_existing_pane) = self + .tabs + .iter() + .find(|(_tab_index, tab)| tab.has_pane_with_pid(&pane_id_of_existing_pane)) + .map(|(_tab_index, tab)| tab.position) + else { + log::error!( + "Could not find tab with pane_id: {:?} to be replaced by", + pane_id_of_existing_pane + ); + return; + }; + let Some(extracted_pane_from_other_tab) = self + .tabs + .iter_mut() + .find(|(_, t)| t.position == tab_index_of_existing_pane) + .and_then(|(_, t)| t.extract_pane(pane_id_of_existing_pane, true)) + else { + log::error!("Failed to find pane"); + return; + }; + if let Some(tab) = self + .tabs + .iter_mut() + .find(|(_, t)| t.position == tab_index_of_pane_id_to_replace) + { + if suppress_replaced_pane { + tab.1.suppress_pane_and_replace_with_other_pane( + pane_id_to_replace, + extracted_pane_from_other_tab, + None, + ); + } else { + tab.1.close_pane_and_replace_with_other_pane( + pane_id_to_replace, + extracted_pane_from_other_tab, + None, + ); + } + } + let _ = self.log_and_report_session_state(); + } + pub fn reconfigure( + &mut self, + new_keybinds: Keybinds, + new_default_mode: InputMode, + theme: Styling, + simplified_ui: bool, + default_shell: Option<PathBuf>, + pane_frame_style: PaneFrameStyle, + copy_command: Option<String>, + copy_to_clipboard: Option<Clipboard>, + copy_on_select: bool, + auto_layout: bool, + rounded_corners: bool, + hide_session_name: bool, + stacked_resize: bool, + stacked_pane_list: bool, + default_editor: Option<PathBuf>, + advanced_mouse_actions: bool, + mouse_scroll_resize: bool, + mouse_hover_effects: bool, + visual_bell: bool, + focus_follows_mouse: bool, + mouse_click_through: bool, + nested_session_handling: NestedSessionHandling, + client_id: ClientId, + ) -> Result<()> { + let should_support_arrow_fonts = !simplified_ui; + self.arrow_fonts = should_support_arrow_fonts; + + // global configuration + self.default_mode_info.update_theme(theme); + self.default_mode_info + .update_rounded_corners(rounded_corners); + // `default_mode_info` is the fallback used by `change_mode` for + // clients that don't yet have a per-client `mode_info` entry, so its + // keybinds and base mode must be kept in sync with reconfigures. + self.default_mode_info.update_keybinds(new_keybinds.clone()); + self.default_mode_info.update_default_mode(new_default_mode); + self.default_shell = default_shell.clone().unwrap_or_else(|| get_default_shell()); + self.default_editor = default_editor.clone().or_else(|| get_default_editor()); + self.auto_layout = auto_layout; + self.copy_options.command = copy_command.clone(); + self.copy_options.copy_on_select = copy_on_select; + self.pane_frame_style = pane_frame_style; + self.advanced_mouse_actions = advanced_mouse_actions; + self.mouse_scroll_resize = mouse_scroll_resize; + self.mouse_hover_effects = mouse_hover_effects; + self.visual_bell = visual_bell; + self.focus_follows_mouse = focus_follows_mouse; + self.mouse_click_through = mouse_click_through; + self.nested_session_handling = nested_session_handling; + self.default_mode_info + .update_arrow_fonts(should_support_arrow_fonts); + self.default_mode_info + .update_hide_session_name(hide_session_name); + { + *self.stacked_resize.borrow_mut() = stacked_resize; + } + { + *self.stacked_pane_list.borrow_mut() = stacked_pane_list; + } + if let Some(copy_to_clipboard) = copy_to_clipboard { + self.copy_options.clipboard = copy_to_clipboard; + } + for tab in self.tabs.values_mut() { + tab.update_theme(theme); + tab.update_rounded_corners(rounded_corners); + tab.update_default_shell(default_shell.clone()); + tab.update_default_editor(self.default_editor.clone()); + tab.update_auto_layout(auto_layout); + tab.update_copy_options(&self.copy_options); + tab.set_pane_frames(pane_frame_style); + tab.update_arrow_fonts(should_support_arrow_fonts); + tab.update_advanced_mouse_actions(advanced_mouse_actions); + tab.update_mouse_scroll_resize(mouse_scroll_resize); + tab.update_mouse_hover_effects(mouse_hover_effects); + tab.update_focus_follows_mouse(focus_follows_mouse); + tab.update_mouse_click_through(mouse_click_through); + tab.sync_stacked_pane_list_mode(); + } + + // Clear hover state when disabled + if !mouse_hover_effects { + for tab in self.tabs.values_mut() { + tab.clear_mouse_hover_state(); + } + } + + // client specific configuration + if self.connected_clients_contains(&client_id) { + let mode_info = self + .mode_info + .entry(client_id) + .or_insert_with(|| self.default_mode_info.clone()); + mode_info.update_keybinds(new_keybinds); + mode_info.update_default_mode(new_default_mode); + mode_info.update_theme(theme); + mode_info.update_arrow_fonts(should_support_arrow_fonts); + mode_info.update_hide_session_name(hide_session_name); + for tab in self.tabs.values_mut() { + tab.change_mode_info(mode_info.clone(), client_id); + tab.mark_active_pane_for_rerender(client_id); + } + } + + // this needs to be done separately at the end because it applies some of the above changes + // and propagates them to plugins + for tab in self.tabs.values_mut() { + tab.update_input_modes()?; + } + self.broadcast_nested_shortcuts(); + Ok(()) + } + /// Apply a host-reported color-palette theme mode (CSI 2031 / DSR 997). + /// + /// This is the Phase 2 entry-point: it + /// 1. de-duplicates against the last-known mode, + /// 2. swaps the active palette to the configured `theme_dark` / `theme_light` + /// (when both are configured) by reusing the reconfigure propagation, + /// 3. fans out an `Event::HostTerminalThemeChanged` plugin event, + /// 4. forwards a `CSI ?997;{1|2}n` DSR onto the pty of every terminal pane + /// whose app opted in via `CSI ? 2031 h`. + pub fn update_host_terminal_theme_mode(&mut self, mode: HostTerminalThemeMode) -> Result<()> { + let err_context = || "Failed to update host terminal theme mode".to_string(); + + // dedupe + if self.host_terminal_theme_mode == Some(mode) { + return Ok(()); + } + self.host_terminal_theme_mode = Some(mode); + + // resolve target styling + let resolved = match mode { + HostTerminalThemeMode::Dark => self.host_theme_dark_styling, + HostTerminalThemeMode::Light => self.host_theme_light_styling, + }; + + // theme propagation when both keys configured and the resolved + // styling exists. (If only one of theme_dark/theme_light is set, + // skip auto-switch; the static `theme` stays authoritative.) + let auto_switch_enabled = + self.host_theme_dark_styling.is_some() && self.host_theme_light_styling.is_some(); + if auto_switch_enabled { + if let Some(theme) = resolved { + self.default_mode_info.update_theme(theme); + for tab in self.tabs.values_mut() { + tab.update_theme(theme); + } + // Iterate every connected client (active_tab_ids is the + // canonical "who is connected" map). Iterating + // self.mode_info.keys() would skip any client that has + // never manually changed mode + let client_ids: Vec<ClientId> = self.active_tab_ids.keys().copied().collect(); + let default_for_new = self.default_mode_info.clone(); + for client_id in client_ids { + let mode_info = self + .mode_info + .entry(client_id) + .or_insert_with(|| default_for_new.clone()); + mode_info.update_theme(theme); + let mode_info_clone = mode_info.clone(); + // Push the freshly-themed mode_info into every tab's + // per-client mode_info map. `update_input_modes` below + // reads from THAT map (not Screen's) when fanning out + // ModeUpdate to plugins, so without this step plugins + // receive ModeUpdate carrying the *old* style and the + // status-bar / tab-bar do not repaint. Also mark the + // active pane for rerender so terminal panes refresh + // their borders/title using the new palette. + for tab in self.tabs.values_mut() { + tab.change_mode_info(mode_info_clone.clone(), client_id); + tab.mark_active_pane_for_rerender(client_id); + } + } + for tab in self.tabs.values_mut() { + tab.update_input_modes().with_context(err_context)?; + } + } else { + log::warn!( + "host theme auto-switch enabled but resolved styling missing for {:?}", + mode + ); + } + } + + // fan out the plugin event + self.bus + .senders + .send_to_plugin(PluginInstruction::Update(vec![( + None, + None, + Event::HostTerminalThemeChanged(mode), + )])) + .with_context(err_context)?; + + // forward DSR to opted-in terminal panes + let mut pty_writes: Vec<(Vec<u8>, u32)> = vec![]; + for tab in self.tabs.values_mut() { + for pane_id in tab.get_all_pane_ids() { + if let PaneId::Terminal(terminal_id) = pane_id { + if let Some(pane) = tab.get_pane_with_id_mut(pane_id) { + pane.push_color_palette_dsr(mode); + for bytes in pane.drain_messages_to_pty() { + pty_writes.push((bytes, terminal_id)); + } + } + } + } + } + for (bytes, terminal_id) in pty_writes { + let _ = self + .bus + .senders + .send_to_pty_writer(PtyWriteInstruction::Write(bytes, terminal_id, None)); + } + + self.render(None)?; + Ok(()) + } + /// Apply a manual host-terminal theme mode change requested via the CLI or a + /// keybinding. Surfaces a clear error to the CLI if the auto-switch gate + /// (both `theme_dark` and `theme_light` configured) is not satisfied; + /// otherwise delegates to `update_host_terminal_theme_mode`, which dedupes, + /// repaints, fans out the plugin event, and forwards DSR to opted-in panes. + pub fn apply_manual_host_terminal_theme_mode( + &mut self, + mode: HostTerminalThemeMode, + completion_tx: &mut Option<NotificationEnd>, + ) -> Result<()> { + let auto_switch_enabled = + self.host_theme_dark_styling.is_some() && self.host_theme_light_styling.is_some(); + if !auto_switch_enabled { + if let Some(c) = completion_tx.as_mut() { + c.set_exit_status(1); + c.set_error_message( + "Manual theme switching requires both `theme_dark` and `theme_light` to be configured." + .to_string(), + ); + } + return Ok(()); + } + self.update_host_terminal_theme_mode(mode) + } + pub fn toggle_pane_pinned(&mut self, client_id: ClientId) { + active_tab_and_connected_client_id!( + self, + client_id, + |tab: &mut Tab, client_id: ClientId| { + tab.toggle_pane_pinned(client_id); + } + ); + } + pub fn set_floating_pane_pinned(&mut self, pane_id: PaneId, should_be_pinned: bool) { + let mut found = false; + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.set_floating_pane_pinned(pane_id, should_be_pinned); + found = true; + break; + } + } + if !found { + log::error!( + "Failed to find pane with id: {:?} to set as pinned", + pane_id + ); + } + } + pub fn stack_panes(&mut self, mut pane_ids_to_stack: Vec<PaneId>) -> Option<PaneId> { + // if successful, returns the pane id of the last pane in the stack + if pane_ids_to_stack.is_empty() { + log::error!("Got an empty list of pane_ids to stack"); + return None; + } + let stack_size = pane_ids_to_stack.len(); + let root_pane_id = pane_ids_to_stack.remove(0); + let last_pane_id = pane_ids_to_stack.last(); + let Some(root_tab_id) = self + .tabs + .iter() + .find_map(|(tab_id, tab)| { + if tab.has_pane_with_pid(&root_pane_id) { + Some(tab_id) + } else { + None + } + }) + .copied() + else { + log::error!("Failed to find tab for root_pane_id: {:?}", root_pane_id); + return None; + }; + let root_pane_id_is_floating = self + .tabs + .get(&root_tab_id) + .map(|t| t.pane_id_is_floating(&root_pane_id)) + .unwrap_or(false); + + if root_pane_id_is_floating { + self.tabs.get_mut(&root_tab_id).map(|tab| { + let _ = tab.toggle_pane_embed_or_floating_for_pane_id(root_pane_id, None); + }); + } + + let mut panes_to_stack = vec![]; + let target_tab_has_room_for_stack = self + .tabs + .get_mut(&root_tab_id) + .map(|t| t.has_room_for_stack(root_pane_id, stack_size)) + .unwrap_or(false); + if !target_tab_has_room_for_stack { + log::error!("No room for stack with root pane id: {:?}", root_pane_id); + return None; + } + + for (tab_id, tab) in self.tabs.iter_mut() { + if tab_id == &root_tab_id { + // we do this before we extract panes so that the extraction won't trigger a + // relayout according to the next swapped tiled pane + tab.set_tiled_panes_damaged(); + } + for pane_id in &pane_ids_to_stack { + if tab.has_pane_with_pid(&pane_id) { + match tab.extract_pane(*pane_id, false) { + Some(pane) => { + panes_to_stack.push(pane); + }, + None => { + log::error!("Failed to extract pane: {:?}", pane_id); + }, + } + } + } + } + self.tabs + .get_mut(&root_tab_id) + .map(|t| t.stack_panes(root_pane_id, panes_to_stack)); + return last_pane_id.copied(); + } + pub fn change_floating_panes_coordinates( + &mut self, + pane_ids_and_coordinates: Vec<(PaneId, FloatingPaneCoordinates)>, + ) { + for (pane_id, coordinates) in pane_ids_and_coordinates { + for (_tab_id, tab) in self.tabs.iter_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.change_floating_pane_coordinates(&pane_id, coordinates) + .non_fatal(); + break; + } + } + } + } + pub fn toggle_pane_borderless(&mut self, pane_id: PaneId) { + for (_tab_id, tab) in self.tabs.iter_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.toggle_pane_borderless(&pane_id).non_fatal(); + break; + } + } + } + pub fn set_pane_borderless(&mut self, pane_id: PaneId, borderless: bool) { + for (_tab_id, tab) in self.tabs.iter_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.set_pane_borderless(&pane_id, borderless).non_fatal(); + break; + } + } + } + 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; + let active_pane_id_before = self + .get_active_tab(client_id) + .ok() + .and_then(|tab| tab.get_active_pane_id(client_id)); + match self + .get_active_tab_mut(client_id) + .and_then(|tab| tab.handle_mouse_event(&event, client_id)) + { + Ok(mouse_effect) => { + let mut should_render = false; + if let Some(pane_id) = mouse_effect.group_toggle { + if self.advanced_mouse_actions { + self.toggle_pane_id_in_group(pane_id, &client_id); + should_render = true; + } + } + if let Some(pane_id) = mouse_effect.group_add { + if self.advanced_mouse_actions { + self.add_pane_id_to_group(pane_id, &client_id); + should_render = true; + } + } + if mouse_effect.ungroup { + if self.advanced_mouse_actions { + self.clear_pane_group(&client_id); + should_render = true; + } + } + if mouse_effect.state_changed { + if !is_bare_motion { + let _ = self.log_and_report_session_state(); + } + let active_pane_id_after = self + .get_active_tab(client_id) + .ok() + .and_then(|tab| tab.get_active_pane_id(client_id)); + if active_pane_id_before.is_some() + && active_pane_id_before != active_pane_id_after + { + self.clear_bell_for_focused_pane(client_id); + if let (Some(old), Some(new)) = + (active_pane_id_before, active_pane_id_after) + { + self.report_key_passthrough_state(client_id, old, new); + } + } + should_render = true; + } + 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 + .into_iter() + .map(|pid| (Some(pid), Some(client_id), Event::InputReceived)) + .collect(); + if !plugin_updates.is_empty() { + let _ = self + .bus + .senders + .send_to_plugin(PluginInstruction::Update(plugin_updates)); + } + should_render = true; + } + if should_render { + self.render(None).non_fatal(); + } + }, + Err(e) => { + log::error!("Failed to process MouseEvent: {}", e); + }, + } + } + pub fn toggle_pane_in_group(&mut self, client_id: ClientId) -> Result<()> { + let err_context = "Can't add pane to group"; + let active_tab = self + .get_active_tab(client_id) + .with_context(|| err_context)?; + let active_pane_id = active_tab + .get_active_pane_id(client_id) + .with_context(|| err_context)?; + self.toggle_pane_id_in_group(active_pane_id, &client_id); + let _ = self.log_and_report_session_state(); + Ok(()) + } + pub fn toggle_group_marking(&mut self, client_id: ClientId) -> Result<()> { + let (was_marking_before, marking_pane_group_now) = { + let mut currently_marking_pane_group = self.currently_marking_pane_group.borrow_mut(); + let previous_value = currently_marking_pane_group + .remove(&client_id) + .unwrap_or(false); + let new_value = !previous_value; + if new_value { + currently_marking_pane_group.insert(client_id, true); + } + (previous_value, new_value) + }; + if marking_pane_group_now { + let active_pane_id = self.get_active_pane_id(&client_id); + if let Some(active_pane_id) = active_pane_id { + self.add_pane_id_to_group(active_pane_id, &client_id); + } + } + let value_changed = was_marking_before != marking_pane_group_now; + if value_changed { + for tab in self.tabs.values_mut() { + tab.update_input_modes()?; + } + let _ = self.log_and_report_session_state(); + } + Ok(()) + } + fn get_layout_metadata( + &self, + default_shell: Option<PathBuf>, + tab_index: Option<usize>, + ) -> SessionLayoutMetadata { + let mut session_layout_metadata = SessionLayoutMetadata::new(self.default_layout.clone()); + if let Some(default_shell) = default_shell { + session_layout_metadata.update_default_shell(default_shell); + } + let first_client_id = self.get_first_client_id(); + let active_tab_index = + first_client_id.and_then(|client_id| self.active_tab_ids.get(&client_id)); + + // Filter tabs based on optional tab_index parameter + let tabs_to_process: Vec<_> = self + .tabs + .iter() + .filter(|(idx, _)| tab_index.map_or(true, |target| **idx == target)) + .collect(); + + for (tab_index, tab) in tabs_to_process { + let tab_is_focused = active_tab_index == Some(&tab_index); + let hide_floating_panes = !tab.are_floating_panes_visible(); + let mut suppressed_panes = HashMap::new(); + for (triggering_pane_id, p) in tab.get_suppressed_panes() { + suppressed_panes.insert(*triggering_pane_id, p); + } + + let all_connected_clients: Vec<ClientId> = self + .connected_clients + .borrow() + .iter() + .map(|(c, _i)| *c) + .filter(|c| self.active_tab_ids.get(&c) == Some(&tab_index)) + .collect(); + + let mut active_pane_ids: HashMap<ClientId, Option<PaneId>> = HashMap::new(); + for connected_client_id in &all_connected_clients { + active_pane_ids.insert( + *connected_client_id, + tab.get_active_pane_id(*connected_client_id), + ); + } + + let stack_list_geoms = tab.stack_list_serialization_geoms(); + let tiled_panes: Vec<PaneLayoutMetadata> = tab + .get_tiled_panes() + .map(|(pane_id, p)| { + // here we look to see if this pane triggers any suppressed pane, + // and if so we take that suppressed pane - we do this because this + // is currently only the case the scrollback editing panes, and + // when dumping the layout we want the "real" pane and not the + // editor pane + let geom_override = stack_list_geoms.get(pane_id).copied(); + let (pane_id, p) = match suppressed_panes.remove(pane_id) { + Some((is_scrollback_editor, suppressed_pane)) if *is_scrollback_editor => { + (suppressed_pane.pid(), suppressed_pane) + }, + _ => (*pane_id, p), + }; + ( + pane_id, + p, + geom_override.unwrap_or_else(|| p.position_and_size()), + ) + }) + .chain(tab.hidden_stack_list_members_for_serialization()) + .map(|(pane_id, p, geom)| { + let focused_clients: Vec<ClientId> = active_pane_ids + .iter() + .filter_map(|(c_id, p_id)| { + p_id.and_then(|p_id| if p_id == pane_id { Some(*c_id) } else { None }) + }) + .collect(); + let (default_fg, default_bg) = p.get_pane_default_colors(); + PaneLayoutMetadata::new( + pane_id, + geom, + p.borderless(), + p.invoked_with().clone(), + p.custom_title(), + !focused_clients.is_empty(), + if self.serialize_pane_viewport { + p.serialize(self.scrollback_lines_to_serialize) + } else { + None + }, + focused_clients, + default_fg, + default_bg, + ) + }) + .collect(); + let floating_panes: Vec<PaneLayoutMetadata> = tab + .get_floating_panes() + .map(|(pane_id, p)| { + // here we look to see if this pane triggers any suppressed pane, + // and if so we take that suppressed pane - we do this because this + // is currently only the case the scrollback editing panes, and + // when dumping the layout we want the "real" pane and not the + // editor pane + match suppressed_panes.remove(pane_id) { + Some((is_scrollback_editor, suppressed_pane)) if *is_scrollback_editor => { + (suppressed_pane.pid(), suppressed_pane) + }, + _ => (*pane_id, p), + } + }) + .map(|(pane_id, p)| { + let focused_clients: Vec<ClientId> = active_pane_ids + .iter() + .filter_map(|(c_id, p_id)| { + p_id.and_then(|p_id| if p_id == pane_id { Some(*c_id) } else { None }) + }) + .collect(); + let (default_fg, default_bg) = p.get_pane_default_colors(); + PaneLayoutMetadata::new( + pane_id, + p.position_and_size(), + false, // floating panes are never borderless + p.invoked_with().clone(), + p.custom_title(), + !focused_clients.is_empty(), + if self.serialize_pane_viewport { + p.serialize(self.scrollback_lines_to_serialize) + } else { + None + }, + focused_clients, + default_fg, + default_bg, + ) + }) + .collect(); + session_layout_metadata.add_tab( + tab.name.clone(), + tab_is_focused, + hide_floating_panes, + tiled_panes, + floating_panes, + ); + } + session_layout_metadata + } + fn update_plugin_loading_stage( + &mut self, + pid: u32, + loading_indication: LoadingIndication, + ) -> bool { + let all_tabs = self.get_tabs_mut(); + let mut found_plugin = false; + for tab in all_tabs.values_mut() { + if tab.has_plugin(pid) { + found_plugin = true; + tab.update_plugin_loading_stage(pid, loading_indication); + break; + } + } + found_plugin + } + fn connected_clients_contains(&self, client_id: &ClientId) -> bool { + self.connected_clients.borrow().contains_key(client_id) + } + fn get_client_pane_group(&self, client_id: &ClientId) -> HashSet<PaneId> { + self.current_pane_group + .borrow() + .get_client_pane_group(client_id) + } + fn clear_pane_group(&mut self, client_id: &ClientId) { + self.current_pane_group + .borrow_mut() + .clear_pane_group(client_id); + self.currently_marking_pane_group + .borrow_mut() + .remove(client_id); + } + fn toggle_pane_id_in_group(&mut self, pane_id: PaneId, client_id: &ClientId) { + { + let mut pane_groups = self.current_pane_group.borrow_mut(); + pane_groups.toggle_pane_id_in_group(pane_id, self.size, client_id); + } + self.retain_only_existing_panes_in_pane_groups(); + } + fn add_pane_id_to_group(&mut self, pane_id: PaneId, client_id: &ClientId) { + { + let mut pane_groups = self.current_pane_group.borrow_mut(); + pane_groups.add_pane_id_to_group(pane_id, self.size, client_id); + } + self.retain_only_existing_panes_in_pane_groups(); + } + fn add_active_pane_to_group_if_marking(&mut self, client_id: &ClientId) { + { + if self + .currently_marking_pane_group + .borrow() + .get(client_id) + .copied() + .unwrap_or(false) + { + let active_pane_id = self.get_active_pane_id(&client_id); + if let Some(active_pane_id) = active_pane_id { + self.add_pane_id_to_group(active_pane_id, &client_id); + } + } + } + self.retain_only_existing_panes_in_pane_groups(); + } + fn get_active_pane_id(&self, client_id: &ClientId) -> Option<PaneId> { + let active_tab = self.get_active_tab(*client_id).ok()?; + active_tab.get_active_pane_id(*client_id) + } + + fn get_pane_info(&self, pane_id: PaneId) -> Option<PaneInfo> { + // Search through all tabs to find the pane + for tab in self.tabs.values() { + if let Some(pane_info) = tab.get_pane_info(pane_id) { + return Some(pane_info); + } + } + None + } + + fn get_tab_info(&self, tab_id: usize) -> Option<TabInfo> { + // Look up tab by its stable ID + self.tabs.get(&tab_id).map(|tab| { + let mut all_focused_clients: Vec<ClientId> = self + .active_tab_ids + .iter() + .filter(|(_c_id, active_tab_id)| **active_tab_id == tab.id) + .map(|(c_id, _)| c_id) + .copied() + .collect(); + for s_id in tab.shadow_focus_clients() { + if !all_focused_clients.contains(&s_id) { + all_focused_clients.push(s_id); + } + } + let (active_swap_layout_name, is_swap_layout_dirty) = tab.swap_layout_info(); + let tab_viewport = tab.get_viewport(); + let tab_display_area = tab.get_display_area(); + let selectable_tiled_panes_count = tab.get_selectable_tiled_panes_count(); + let selectable_floating_panes_count = tab.get_selectable_floating_panes_count(); + + TabInfo { + position: tab.position, + name: tab.name.clone(), + active: self.active_tab_ids.values().any(|i| i == &tab.id), + panes_to_hide: tab.panes_to_hide_count(), + is_fullscreen_active: tab.is_fullscreen_active(), + is_sync_panes_active: tab.is_sync_panes_active(), + are_floating_panes_visible: tab.are_floating_panes_visible(), + other_focused_clients: all_focused_clients, + active_swap_layout_name, + is_swap_layout_dirty, + viewport_rows: tab_viewport.rows, + viewport_columns: tab_viewport.cols, + display_area_rows: tab_display_area.rows, + display_area_columns: tab_display_area.cols, + selectable_tiled_panes_count, + selectable_floating_panes_count, + tab_id: tab.id, + has_bell_notification: tab.tab_has_pending_bell + && !self.active_tab_ids.values().any(|i| i == &tab.id), + is_flashing_bell: tab.tab_bell_flash + && !self.active_tab_ids.values().any(|i| i == &tab.id), + } + }) + } + + fn group_and_ungroup_panes( + &mut self, + pane_ids_to_group: Vec<PaneId>, + pane_ids_to_ungroup: Vec<PaneId>, + for_all_clients: bool, + client_id: ClientId, + ) { + if for_all_clients { + { + let mut current_pane_group = self.current_pane_group.borrow_mut(); + current_pane_group.group_and_ungroup_panes_for_all_clients( + pane_ids_to_group, + pane_ids_to_ungroup, + self.size, + ); + } + } else { + { + let mut current_pane_group = self.current_pane_group.borrow_mut(); + current_pane_group.group_and_ungroup_panes( + pane_ids_to_group, + pane_ids_to_ungroup, + self.size, + &client_id, + ); + } + } + self.retain_only_existing_panes_in_pane_groups(); + let _ = self.log_and_report_session_state(); + } + fn retain_only_existing_panes_in_pane_groups(&mut self) { + let clients_with_empty_group = { + let mut clients_with_empty_group = vec![]; + let mut current_pane_group = { self.current_pane_group.borrow().clone_inner() }; + for (client_id, panes_in_group) in current_pane_group.iter_mut() { + let all_tabs = self.get_tabs(); + panes_in_group.retain(|p_id| { + let mut found = false; + for tab in all_tabs.values() { + if tab.has_pane_with_pid(&p_id) { + found = true; + break; + } + } + found + }); + if panes_in_group.is_empty() { + clients_with_empty_group.push(*client_id) + } + } + self.current_pane_group + .borrow_mut() + .override_groups_with(current_pane_group); + clients_with_empty_group + }; + for client_id in &clients_with_empty_group { + self.currently_marking_pane_group + .borrow_mut() + .remove(client_id); + } + if !clients_with_empty_group.is_empty() { + let all_tabs = self.get_tabs_mut(); + for tab in all_tabs.values_mut() { + let _ = tab.update_input_modes(); + } + } + } + fn update_active_pane_ids(&mut self) { + let connected_clients: Vec<ClientId> = + self.connected_clients.borrow().keys().copied().collect(); + for client_id in connected_clients { + if let Some(active_pane_id) = self.get_active_pane_id(&client_id) { + let active_pane_id: PaneId = active_pane_id.into(); + let history = self.pane_history.entry(client_id).or_insert_with(|| vec![]); + history.retain(|e| e != &active_pane_id); + history.push(active_pane_id.into()); + } + } + } + fn subscribe_to_pane_renders( + &mut self, + subscriber_client_id: ClientId, + pane_ids: Vec<zellij_utils::data::PaneId>, + scrollback: Option<usize>, + ansi: bool, + ) { + 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 = if ansi { + pane.pane_contents_with_ansi( + query_client_id, + get_full_scrollback, + max_lines, + ) + } else { + 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, + ansi, + }, + ); + } + } + fn deliver_to_pane_subscribers_from_report(&mut self, report: &PaneRenderReport) { + let Some(pane_map) = report.all_pane_contents.values().next() else { + return; + }; + let ansi_pane_map = report.all_pane_contents_with_ansi.values().next(); + self.deliver_subscriber_updates_from_map(pane_map, ansi_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(); + + let has_ansi_subscribers = self.pane_render_subscribers.values().any(|s| s.ansi); + + // Query pane contents directly from tabs + let mut pane_map: HashMap<zellij_utils::data::PaneId, PaneContents> = HashMap::new(); + let mut ansi_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)); + if has_ansi_subscribers { + ansi_pane_map + .insert(*pane_id, pane.pane_contents_with_ansi(None, false, None)); + } + break; + } + } + } + + let ansi_map_ref = if has_ansi_subscribers { + Some(&ansi_pane_map) + } else { + None + }; + self.deliver_subscriber_updates_from_map(&pane_map, ansi_map_ref); + } + fn deliver_subscriber_updates_from_map( + &mut self, + pane_map: &HashMap<zellij_utils::data::PaneId, PaneContents>, + ansi_pane_map: Option<&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 { + let effective_map = if subscription.ansi { + ansi_pane_map.unwrap_or(pane_map) + } else { + pane_map + }; + + for pane_id in &subscription.pane_ids { + if let Some(contents) = effective_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))] +fn get_default_editor() -> Option<PathBuf> { + std::env::var("EDITOR") + .or_else(|_| std::env::var("VISUAL")) + .map(|e| PathBuf::from(e)) + .ok() +} + +#[cfg(test)] +fn get_default_editor() -> Option<PathBuf> { + None +} + +fn find_already_running_panes( + tiled_layout: &TiledPaneLayout, + floating_layouts: &[FloatingPaneLayout], + active_tab: &Tab, +) -> (Vec<Option<Run>>, Vec<usize>) { + let mut layout_tiled_instructions = tiled_layout.extract_run_instructions(); + let running_tiled_instructions: Vec<Option<Run>> = active_tab + .get_tiled_panes() + .map(|(_, pane)| pane.invoked_with().clone()) + .chain( + active_tab + .suppressed_stack_list_members() + .map(|(_, pane)| pane.invoked_with().clone()), + ) + .collect(); + + let mut tiled_to_ignore = Vec::new(); + for running_instr in running_tiled_instructions { + if let Some(pos) = layout_tiled_instructions + .iter() + .position(|layout_instr| layout_instr == &running_instr) + { + layout_tiled_instructions.remove(pos); + tiled_to_ignore.push(running_instr); + } + } + + let mut running_floating_instructions: Vec<Option<Run>> = active_tab + .get_floating_panes() + .map(|(_, pane)| pane.invoked_with().clone()) + .collect(); + + let mut floating_indices = Vec::new(); + for (idx, floating_layout) in floating_layouts.iter().enumerate() { + if let Some(pos) = running_floating_instructions + .iter() + .position(|instr| instr == &floating_layout.run) + { + running_floating_instructions.remove(pos); + floating_indices.push(idx); + } + } + + (tiled_to_ignore, floating_indices) +} + +// The box is here in order to make the +// NewClient enum smaller +#[allow(clippy::boxed_local)] +pub(crate) fn screen_thread_main( + bus: Bus<ScreenInstruction>, + max_panes: Option<usize>, + client_attributes: ClientAttributes, + config: Config, + debug: bool, + default_layout: Box<Layout>, +) -> Result<()> { + // Resolve `theme_dark` / `theme_light` to concrete `Styling` from the + // bundled themes BEFORE `config.options` is moved out below. These + // populate Screen's auto-switch state at startup; runtime updates + // continue to flow through `propagate_configuration_changes`. + let host_theme_dark_styling = config + .options + .theme_dark + .as_ref() + .and_then(|name| config.themes.get_theme(name).map(|t| t.palette)); + let host_theme_light_styling = config + .options + .theme_light + .as_ref() + .and_then(|name| config.themes.get_theme(name).map(|t| t.palette)); + if config.options.theme_dark.is_some() && host_theme_dark_styling.is_none() { + log::warn!( + "theme_dark='{}' not found in themes; auto-theme switch disabled for dark.", + config.options.theme_dark.as_deref().unwrap_or("?") + ); + } + if config.options.theme_light.is_some() && host_theme_light_styling.is_none() { + log::warn!( + "theme_light='{}' not found in themes; auto-theme switch disabled for light.", + config.options.theme_light.as_deref().unwrap_or("?") + ); + } + + let config_options = config.options; + let arrow_fonts = !config_options.simplified_ui.unwrap_or_default(); + let pane_frame_style = PaneFrameStyle::from_options(&config_options); + let auto_layout = config_options.auto_layout.unwrap_or(true); + let session_serialization = config_options.session_serialization.unwrap_or(true); + let serialize_pane_viewport = config_options.serialize_pane_viewport.unwrap_or(false); + let scrollback_lines_to_serialize = config_options.scrollback_lines_to_serialize; + let session_is_mirrored = config_options.mirror_session.unwrap_or(false); + let layout_dir = config_options.layout_dir; + #[cfg(test)] + let default_shell = config_options + .default_shell + .clone() + .unwrap_or(PathBuf::from("/bin/sh")); + #[cfg(not(test))] + let default_shell = config_options + .default_shell + .clone() + .unwrap_or_else(|| get_default_shell()); + let default_editor = config_options + .scrollback_editor + .clone() + .or_else(|| get_default_editor()); + let default_layout_name = config_options + .default_layout + .map(|l| format!("{}", l.display())); + let copy_options = CopyOptions::new( + config_options.copy_command, + config_options.copy_clipboard.unwrap_or_default(), + config_options.copy_on_select.unwrap_or(true), + ); + let web_server_ip = config_options + .web_server_ip + .unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + let web_server_port = config_options.web_server_port.unwrap_or(8082); + let styled_underlines = config_options.styled_underlines.unwrap_or(true); + let osc8_hyperlinks = config_options.osc8_hyperlinks.unwrap_or(true); + let explicitly_disable_kitty_keyboard_protocol = config_options + .support_kitty_keyboard_protocol + .map(|e| !e) // this is due to the config options wording, if + // "support_kitty_keyboard_protocol" is true, + // explicitly_disable_kitty_keyboard_protocol is false and vice versa + .unwrap_or(false); // by default, we try to support this if the terminal supports it and + // the program running inside a pane requests it + let support_kitty_graphics_protocol = config_options + .support_kitty_graphics_protocol + .unwrap_or(true); + let stacked_resize = config_options.stacked_resize.unwrap_or(true); + let stacked_pane_list = config_options.stacked_pane_list.unwrap_or(true); + let web_clients_allowed = config_options + .web_sharing + .map(|s| s.web_clients_allowed()) + .unwrap_or(false); + let web_sharing = config_options.web_sharing.unwrap_or_else(Default::default); + let advanced_mouse_actions = config_options.advanced_mouse_actions.unwrap_or(true); + let mouse_scroll_resize = config_options.mouse_scroll_resize.unwrap_or(true); + let mouse_hover_effects = config_options.mouse_hover_effects.unwrap_or(true); + let visual_bell = config_options.visual_bell.unwrap_or(true); + let focus_follows_mouse = config_options.focus_follows_mouse.unwrap_or(false); + let mouse_click_through = config_options.mouse_click_through.unwrap_or(false); + let nested_session_handling = config_options.nested_session_handling.unwrap_or_default(); + + let thread_senders = bus.senders.clone(); + let mut screen = Screen::new( + bus, + &client_attributes, + max_panes, + get_mode_info( + config_options.default_mode.unwrap_or_default(), + &client_attributes, + PluginCapabilities { + // ¯\_(ツ)_/¯ + arrow_fonts: !arrow_fonts, + }, + &config.keybinds, + config_options.default_mode, + ), + pane_frame_style, + 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, + support_kitty_graphics_protocol, + stacked_resize, + stacked_pane_list, + default_editor, + web_clients_allowed, + web_sharing, + advanced_mouse_actions, + mouse_scroll_resize, + mouse_hover_effects, + visual_bell, + focus_follows_mouse, + mouse_click_through, + web_server_ip, + web_server_port, + nested_session_handling, + ); + screen.host_theme_dark_styling = host_theme_dark_styling; + screen.host_theme_light_styling = host_theme_light_styling; + + let mut pending_tab_ids: HashSet<usize> = HashSet::new(); + let mut pending_tab_switches: HashSet<(usize, ClientId)> = HashSet::new(); // usize is the + // tab_index + let mut pending_events_waiting_for_tab: Vec<ScreenInstruction> = vec![]; + let mut pending_events_waiting_for_client: Vec<ScreenInstruction> = vec![]; + let mut pending_events_waiting_for_pane: HashMap<PaneId, Vec<ScreenInstruction>> = + HashMap::new(); + let mut plugin_loading_message_cache = HashMap::new(); + let mut keybind_intercepts = HashMap::new(); + loop { + let (event, mut err_ctx) = screen + .bus + .recv() + .context("failed to receive event on channel")?; + err_ctx.add_call(ContextType::Screen((&event).into())); + // here we start caching resizes, so that we'll send them in bulk at the end of each event + // when this cache is Dropped, for more information, see the comments in PtyWriter + let _resize_cache = ResizeCache::new(thread_senders.clone()); + + match event { + ScreenInstruction::PtyBytes(pid, vte_bytes) => { + let all_tabs = screen.get_tabs_mut(); + let mut vte_bytes = Some(vte_bytes); + for tab in all_tabs.values_mut() { + if tab.has_terminal_pid(pid) { + if let Some(bytes) = vte_bytes.take() { + tab.handle_pty_bytes(pid, bytes) + .context("failed to process pty bytes")?; + } + break; + } + } + if let Some(vte_bytes) = vte_bytes { + pending_events_waiting_for_pane + .entry(PaneId::Terminal(pid)) + .or_default() + .push(ScreenInstruction::PtyBytes(pid, vte_bytes)); + } + let _ = screen + .bus + .senders + .send_to_background_jobs(BackgroundJob::RenderToClients); + }, + ScreenInstruction::PluginBytes(mut plugin_render_assets) => { + for plugin_render_asset in plugin_render_assets.iter_mut() { + let plugin_id = plugin_render_asset.plugin_id; + let client_id = plugin_render_asset.client_id; + let vte_bytes: Vec<u8> = plugin_render_asset.bytes.drain(..).collect(); + let painted_content = !vte_bytes.is_empty(); + + let mobile_tab_id = screen.mobile_state.mobile_tab_id(client_id); + let mut painted_tab_id = None; + let mut painted_size = None; + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_plugin(plugin_id) { + tab.handle_plugin_bytes(plugin_id, client_id, vte_bytes) + .context("failed to process plugin bytes")?; + painted_tab_id = Some(tab.id); + painted_size = + tab.get_pane_with_id(PaneId::Plugin(plugin_id)) + .map(|pane| Size { + rows: pane.get_content_rows(), + cols: pane.get_content_columns(), + }); + break; + } + } + if painted_content && painted_tab_id == mobile_tab_id && mobile_tab_id.is_some() + { + if let Some(painted_size) = painted_size { + screen + .mobile_render_gate + .record_paint_size(client_id, painted_size); + } + screen.try_lift_mobile_gate(client_id); + } + screen.render_blocker.remove_blocking_plugin(plugin_id); + } + screen.render(Some(plugin_render_assets))?; + }, + ScreenInstruction::Render => { + screen.render(None)?; + }, + ScreenInstruction::RenderToClients => { + // render_blocker.can_render() returning true means that either all pending plugins + // (only those waiting for a new tab layout to be applied!) have been rendered or + // that a 100ms timeout has been reached (more info in the RenderBlocker comment) + if screen.render_blocker.can_render() { + screen.render_to_clients()?; + } else { + screen.render(None)?; + } + }, + ScreenInstruction::NewPane( + pid, + initial_pane_title, + hold_for_command, + invoked_with, + new_pane_placement, + start_suppressed, + client_or_tab_index, + mut completion_tx, + set_blocking, + ) => { + completion_tx.as_mut().map(|c| c.set_affected_pane_id(pid)); + + let blocking_notification = if set_blocking { completion_tx } else { None }; + + match client_or_tab_index { + ClientTabIndexOrPaneId::ClientId(client_id) + | ClientTabIndexOrPaneId::ClientIdNoFocus(client_id) => { + let should_focus_pane = + matches!(client_or_tab_index, ClientTabIndexOrPaneId::ClientId(_)); + active_tab_and_connected_client_id_with_first_tab_fallback!(screen, client_id, |tab: &mut Tab, client_id: Option<ClientId>| { + tab.new_pane(pid, + initial_pane_title, + invoked_with, + start_suppressed, + should_focus_pane, + new_pane_placement, + client_id, + blocking_notification + ) + }, ?); + if let Some(hold_for_command) = hold_for_command { + let is_first_run = true; + active_tab_and_connected_client_id_with_first_tab_fallback!( + screen, + client_id, + |tab: &mut Tab, _client_id: Option<ClientId>| tab.hold_pane( + pid, + None, + is_first_run, + hold_for_command + ) + ) + } + }, + ClientTabIndexOrPaneId::TabIndex(tab_index) + | ClientTabIndexOrPaneId::TabIndexNoFocus(tab_index) => { + let should_focus_pane = + matches!(client_or_tab_index, ClientTabIndexOrPaneId::TabIndex(_)); + // Some placements (directional split, stacked without a + // target pane) need a client_id to know which pane to + // split relative to. Only resolve one when required. + let needs_client_id = matches!( + new_pane_placement, + NewPanePlacement::Tiled { + direction: Some(_), + .. + } | NewPanePlacement::Stacked { + pane_id_to_stack_under: None, + .. + } + ); + let client_id = if needs_client_id { + screen + .active_tab_ids + .iter() + .find(|(_, tid)| **tid == tab_index) + .map(|(cid, _)| *cid) + .or_else(|| screen.active_tab_ids.keys().next().copied()) + } else { + None + }; + if let Some(active_tab) = screen.tabs.get_mut(&tab_index) { + active_tab.new_pane( + pid, + initial_pane_title, + invoked_with, + start_suppressed, + should_focus_pane, + new_pane_placement, + client_id, + blocking_notification, + )?; + if let Some(hold_for_command) = hold_for_command { + let is_first_run = true; + active_tab.hold_pane(pid, None, is_first_run, hold_for_command); + } + } else { + log::error!("Tab index not found: {:?}", tab_index); + } + }, + ClientTabIndexOrPaneId::PaneId(pane_id) => { + let mut found = false; + let all_tabs = screen.get_tabs_mut(); + let should_focus_pane = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + match new_pane_placement { + NewPanePlacement::Tiled { + direction: Some(direction), + borderless, + } => { + if direction == Direction::Left + || direction == Direction::Right + { + tab.vertical_split_of_pane_id( + pid, + initial_pane_title, + invoked_with, + pane_id, + blocking_notification, + borderless, + )?; + } else { + tab.horizontal_split_of_pane_id( + pid, + initial_pane_title, + invoked_with, + pane_id, + blocking_notification, + borderless, + )?; + } + }, + _ => { + tab.new_pane( + pid, + initial_pane_title, + invoked_with, + start_suppressed, + should_focus_pane, + new_pane_placement, + None, + blocking_notification, + )?; + }, + } + if let Some(hold_for_command) = hold_for_command { + let is_first_run = true; + tab.hold_pane(pid, None, is_first_run, hold_for_command); + } + found = true; + break; + } + } + if !found { + log::error!( + "Failed to find tab containing pane with id: {:?}", + pane_id + ); + } + }, + }; + if let Some(pending_events) = pending_events_waiting_for_pane.remove(&pid) { + for event in pending_events { + screen.bus.senders.send_to_screen(event).non_fatal(); + } + } + screen.log_and_report_session_state()?; + + screen.render(None)?; + }, + ScreenInstruction::OpenInPlaceEditor(pid, client_tab_index_or_pane_id) => { + match client_tab_index_or_pane_id { + ClientTabIndexOrPaneId::ClientId(client_id) + | ClientTabIndexOrPaneId::ClientIdNoFocus(client_id) => { + active_tab!(screen, client_id, |tab: &mut Tab| tab + .replace_active_pane_with_editor_pane(pid, client_id), ?); + screen.log_and_report_session_state()?; + }, + ClientTabIndexOrPaneId::TabIndex(_tab_index) + | ClientTabIndexOrPaneId::TabIndexNoFocus(_tab_index) => { + log::error!("Cannot OpenInPlaceEditor with a TabIndex"); + }, + ClientTabIndexOrPaneId::PaneId(pane_id_to_replace) => { + let mut found = false; + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id_to_replace) { + tab.replace_pane_with_editor_pane(pid, pane_id_to_replace) + .non_fatal(); + found = true; + break; + } + } + if !found { + log::error!( + "Could not find pane with id {:?} to replace", + pane_id_to_replace + ); + } + }, + } + + if let Some(pending_events) = pending_events_waiting_for_pane.remove(&pid) { + for event in pending_events { + screen.bus.senders.send_to_screen(event).non_fatal(); + } + } + + screen.render(None)?; + }, + ScreenInstruction::TogglePaneEmbedOrFloating( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!(screen, client_id, |tab: &mut Tab, client_id: ClientId| tab + .toggle_pane_embed_or_floating(client_id), ?); + screen.log_and_report_session_state()?; + screen.render(None)?; + }, + ScreenInstruction::ToggleFloatingPanes(client_id, default_shell, completion_tx) => { + active_tab_and_connected_client_id!(screen, client_id, |tab: &mut Tab, client_id: ClientId| tab + .toggle_floating_panes(Some(client_id), default_shell, completion_tx), ?); + screen.clear_bell_for_focused_pane(client_id); + screen.log_and_report_session_state()?; + + screen.render(None)?; + }, + ScreenInstruction::ShowFloatingPanes { + client_id, + tab_id, + completion, + } => { + screen.show_floating_panes_in_tab(client_id, tab_id, completion)?; + screen.log_and_report_session_state()?; + screen.render(None)?; + }, + ScreenInstruction::HideFloatingPanes { + client_id, + tab_id, + completion, + } => { + screen.hide_floating_panes_in_tab(client_id, tab_id, completion)?; + screen.log_and_report_session_state()?; + screen.render(None)?; + }, + ScreenInstruction::AreFloatingPanesVisible { + client_id, + tab_id, + completion, + } => { + screen.are_floating_panes_visible_in_tab(client_id, tab_id, completion)?; + }, + ScreenInstruction::WriteCharacter( + key_with_modifier, + raw_bytes, + is_kitty_keyboard_protocol, + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + if let Some(plugin_id) = keybind_intercepts.get(&client_id) { + if let Some(key_with_modifier) = key_with_modifier { + let _ = screen + .bus + .senders + .send_to_plugin(PluginInstruction::Update(vec![( + Some(*plugin_id), + Some(client_id), + Event::InterceptedKeyPress(key_with_modifier), + )])); + continue; + } + } + let mut state_changed = false; + let client_input_mode = screen.get_client_input_mode(client_id); + match client_input_mode { + Some(InputMode::RenameTab) => { + if !(raw_bytes == BRACKETED_PASTE_BEGIN || raw_bytes == BRACKETED_PASTE_END) + { + screen.update_active_tab_name(raw_bytes, client_id)?; + state_changed = true; + } + }, + _ => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| { + match client_input_mode { + Some(InputMode::EnterSearch) => { + if !(raw_bytes == BRACKETED_PASTE_BEGIN + || raw_bytes == BRACKETED_PASTE_END) + { + if let Err(e) = + tab.update_search_term(raw_bytes, client_id) + { + log::error!("{}", e); + } + } + state_changed = true; + }, + Some(InputMode::RenamePane) => { + if !(raw_bytes == BRACKETED_PASTE_BEGIN + || raw_bytes == BRACKETED_PASTE_END) + { + if let Err(e) = + tab.update_active_pane_name(raw_bytes, client_id) + { + log::error!("{}", e); + } + state_changed = true; + } + }, + _ => { + let write_result = match tab.is_sync_panes_active() { + true => tab.write_to_terminals_on_current_tab( + &key_with_modifier, + raw_bytes, + is_kitty_keyboard_protocol, + client_id, + ), + false => tab.write_to_active_terminal( + &key_with_modifier, + raw_bytes, + is_kitty_keyboard_protocol, + client_id, + ), + }; + if let Ok(true) = write_result { + state_changed = true; + } + }, + } + } + ); + }, + }; + if state_changed { + screen.log_and_report_session_state()?; + } + screen.render(None)?; + }, + ScreenInstruction::Resize( + client_id, + strategy, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.resize(client_id, strategy), + ? + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::SwitchFocus(client_id, _completion_tx) => { + let old_pane_id = screen.get_active_pane_id(&client_id); + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.focus_next_pane(client_id) + ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + screen.report_key_passthrough_state(client_id, old, new); + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::FocusNextPane(client_id, mut _completion_tx) => { + if screen.get_first_client_id().is_none() { + log::error!("No connected clients to change focus for"); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message("No connected clients to change focus for".to_string()); + } + } else { + let old_pane_id = screen.get_active_pane_id(&client_id); + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.focus_next_pane(client_id) + ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + screen.report_key_passthrough_state(client_id, old, new); + } + screen.render(None)?; + } + }, + ScreenInstruction::FocusPreviousPane(client_id, mut _completion_tx) => { + if screen.get_first_client_id().is_none() { + log::error!("No connected clients to change focus for"); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message("No connected clients to change focus for".to_string()); + } + } else { + let old_pane_id = screen.get_active_pane_id(&client_id); + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.focus_previous_pane(client_id) + ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + screen.report_key_passthrough_state(client_id, old, new); + } + screen.render(None)?; + screen.log_and_report_session_state()?; + } + }, + ScreenInstruction::FocusLastPane(client_id, _completion_tx) => { + let old_pane_id = screen.get_active_pane_id(&client_id); + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.focus_last_pane(client_id) + ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + screen.report_key_passthrough_state(client_id, old, new); + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::MoveFocusLeft(client_id, mut _completion_tx) => { + if screen.get_first_client_id().is_none() { + log::error!("No connected clients to move focus for"); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message("No connected clients to move focus for".to_string()); + } + } else { + let old_pane_id = screen.get_active_pane_id(&client_id); + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.move_focus_left(client_id), + ? + ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + if old == new { + screen.bubble_focus_to_host(client_id, Direction::Left); + } else { + screen.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(Direction::Left), + ); + } + } + screen.clear_bell_for_focused_pane(client_id); + screen.add_active_pane_to_group_if_marking(&client_id); + screen.render(None)?; + screen.log_and_report_session_state()?; + } + }, + ScreenInstruction::MoveFocusLeftOrPreviousTab(client_id, mut _completion_tx) => { + if screen.get_first_client_id().is_none() { + log::error!("No connected clients to move focus for"); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message("No connected clients to move focus for".to_string()); + } + } else { + let old_pane_id = screen.get_active_pane_id(&client_id); + screen.move_focus_left_or_previous_tab(client_id)?; + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + if old != new { + screen.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(Direction::Left), + ); + } + } + screen.clear_bell_for_focused_pane(client_id); + screen.add_active_pane_to_group_if_marking(&client_id); + screen.render(None)?; + screen.log_and_report_session_state()?; + } + }, + ScreenInstruction::MoveFocusDown(client_id, mut _completion_tx) => { + if screen.get_first_client_id().is_none() { + log::error!("No connected clients to move focus for"); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message("No connected clients to move focus for".to_string()); + } + } else { + let old_pane_id = screen.get_active_pane_id(&client_id); + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.move_focus_down(client_id), + ? + ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + if old == new { + screen.bubble_focus_to_host(client_id, Direction::Down); + } else { + screen.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(Direction::Down), + ); + } + } + screen.clear_bell_for_focused_pane(client_id); + screen.add_active_pane_to_group_if_marking(&client_id); + screen.render(None)?; + screen.log_and_report_session_state()?; + } + }, + ScreenInstruction::MoveFocusRight(client_id, mut _completion_tx) => { + if screen.get_first_client_id().is_none() { + log::error!("No connected clients to move focus for"); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message("No connected clients to move focus for".to_string()); + } + } else { + let old_pane_id = screen.get_active_pane_id(&client_id); + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.move_focus_right(client_id), + ? + ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + if old == new { + screen.bubble_focus_to_host(client_id, Direction::Right); + } else { + screen.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(Direction::Right), + ); + } + } + screen.clear_bell_for_focused_pane(client_id); + screen.add_active_pane_to_group_if_marking(&client_id); + screen.render(None)?; + screen.log_and_report_session_state()?; + } + }, + ScreenInstruction::MoveFocusRightOrNextTab(client_id, mut _completion_tx) => { + if screen.get_first_client_id().is_none() { + log::error!("No connected clients to move focus for"); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message("No connected clients to move focus for".to_string()); + } + } else { + let old_pane_id = screen.get_active_pane_id(&client_id); + screen.move_focus_right_or_next_tab(client_id)?; + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + if old != new { + screen.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(Direction::Right), + ); + } + } + screen.clear_bell_for_focused_pane(client_id); + screen.add_active_pane_to_group_if_marking(&client_id); + screen.render(None)?; + screen.log_and_report_session_state()?; + } + }, + ScreenInstruction::MoveFocusUp(client_id, mut _completion_tx) => { + if screen.get_first_client_id().is_none() { + log::error!("No connected clients to move focus for"); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message("No connected clients to move focus for".to_string()); + } + } else { + let old_pane_id = screen.get_active_pane_id(&client_id); + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.move_focus_up(client_id), + ? + ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + if old == new { + screen.bubble_focus_to_host(client_id, Direction::Up); + } else { + screen.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(Direction::Up), + ); + } + } + screen.clear_bell_for_focused_pane(client_id); + screen.add_active_pane_to_group_if_marking(&client_id); + screen.render(None)?; + screen.log_and_report_session_state()?; + } + }, + ScreenInstruction::ClearScreen( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.clear_active_terminal_screen( + client_id, + ), + ? + ); + screen.render(None)?; + }, + ScreenInstruction::DumpScreen( + file, + client_id, + full, + pane_id, + completion_tx, + cli_client_id, + ansi, + ) => { + match file { + Some(file_path) => { + // Write dump to file (existing behavior) + match pane_id { + Some(pane_id) => { + for tab in screen.get_tabs_mut().values_mut() { + if tab.has_pane_with_pid(&pane_id) { + if ansi { + tab.dump_with_ansi_terminal_screen( + Some(file_path.clone()), + pane_id, + full, + )?; + } else { + tab.dump_terminal_screen( + Some(file_path.clone()), + pane_id, + full, + )?; + } + break; + } + } + }, + None => { + if ansi { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.dump_with_ansi_active_terminal_screen( + Some(file_path.to_string()), + client_id, + full + ), + ? + ); + } else { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.dump_active_terminal_screen( + Some(file_path.to_string()), + client_id, + full + ), + ? + ); + } + }, + } + screen.render(None)?; + drop(completion_tx); + }, + None => { + // Dump to STDOUT via Log + let dump = match pane_id { + Some(pane_id) => { + let mut result = String::new(); + for tab in screen.get_tabs_mut().values_mut() { + if tab.has_pane_with_pid(&pane_id) { + if ansi { + if let Some(dump) = tab + .get_dump_with_ansi_terminal_screen(pane_id, full) + { + result = dump; + } + } else { + if let Some(dump) = + tab.get_dump_terminal_screen(pane_id, full) + { + result = dump; + } + } + break; + } + } + result + }, + None => { + let mut result = String::new(); + if ansi { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| { + result = tab.get_dump_with_ansi_active_terminal_screen(client_id, full); + Ok::<(), anyhow::Error>(()) + }, + ? + ); + } else { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| { + result = tab.get_dump_active_terminal_screen(client_id, full); + Ok::<(), anyhow::Error>(()) + }, + ? + ); + } + result + }, + }; + screen.bus.senders.send_to_server(ServerInstruction::Log( + vec![dump], + cli_client_id.unwrap_or(client_id), + completion_tx, + ))?; + }, + } + }, + ScreenInstruction::DumpLayout(default_shell, client_id, completion_tx) => { + let err_context = || format!("Failed to dump layout"); + let session_layout_metadata = screen.get_layout_metadata(default_shell, None); + screen + .bus + .senders + .send_to_plugin(PluginInstruction::DumpLayout( + session_layout_metadata, + client_id, + completion_tx, + )) + .with_context(err_context)?; + }, + ScreenInstruction::ListClientsMetadata(default_shell, client_id, completion_tx) => { + let err_context = || format!("Failed to dump layout"); + let session_layout_metadata = screen.get_layout_metadata(default_shell, None); + screen + .bus + .senders + .send_to_plugin(PluginInstruction::ListClientsMetadata( + session_layout_metadata, + client_id, + completion_tx, + )) + .with_context(err_context)?; + }, + ScreenInstruction::ListPanes { + show_all, + response_channel, + } => { + let err_context = || "Failed to list panes"; + let pane_entries = screen + .collect_pane_list(show_all) + .with_context(err_context)?; + let _ = response_channel.send(pane_entries); + }, + ScreenInstruction::ListTabs { + client_id, + response_channel, + } => { + let err_context = || "Failed to list tabs"; + let tab_infos = screen + .collect_tab_list(client_id) + .with_context(err_context)?; + let _ = response_channel.send(tab_infos); + }, + ScreenInstruction::GetCurrentTabInfo { + client_id, + response_channel, + } => { + let err_context = || "Failed to get current tab info"; + let tab_info = screen + .get_current_tab_info(client_id) + .with_context(err_context)?; + let _ = response_channel.send(tab_info); + }, + ScreenInstruction::DumpLayoutToPlugin { + plugin_id, + tab_index, + response_channel, + } => { + let err_context = || format!("Failed to dump layout"); + let session_layout_metadata = + screen.get_layout_metadata(Some(screen.default_shell.clone()), tab_index); + screen + .bus + .senders + .send_to_pty(PtyInstruction::DumpLayoutToPlugin { + session_layout_metadata, + plugin_id, + response_channel, + }) + .with_context(err_context) + .non_fatal(); + }, + ScreenInstruction::GetFocusedPaneInfo { + client_id, + response_channel, + } => { + let response = match screen.active_tab_ids.get(&client_id) { + Some(&focused_tab_index) => match screen.get_active_pane_id(&client_id) { + Some(focused_pane_id) => GetFocusedPaneInfoResponse::Ok { + tab_index: focused_tab_index, + pane_id: focused_pane_id.into(), + }, + None => GetFocusedPaneInfoResponse::Err(format!( + "No active pane found for client {:?}", + client_id + )), + }, + None => GetFocusedPaneInfoResponse::Err(format!( + "Client {:?} not found in active_tab_indices", + client_id + )), + }; + let _ = response_channel.send(response); + }, + ScreenInstruction::GetPaneInfo { + pane_id, + response_channel, + } => { + let pane_info = screen.get_pane_info(pane_id); + let _ = response_channel.send(pane_info); + }, + ScreenInstruction::GetTabInfo { + tab_id, + response_channel, + } => { + let tab_info = screen.get_tab_info(tab_id); + let _ = response_channel.send(tab_info); + }, + ScreenInstruction::ListClientsToPlugin(plugin_id, client_id) => { + let err_context = || format!("Failed to dump layout"); + let session_layout_metadata = + screen.get_layout_metadata(Some(screen.default_shell.clone()), None); + screen + .bus + .senders + .send_to_pty(PtyInstruction::ListClientsToPlugin( + session_layout_metadata, + plugin_id, + client_id, + )) + .with_context(err_context) + .non_fatal(); + }, + ScreenInstruction::EditScrollback(client_id, ansi, completion_tx) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| { + if ansi { + tab.edit_scrollback_raw(client_id, completion_tx) + } else { + tab.edit_scrollback(client_id, completion_tx) + } + }, + ? + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::GetPaneScrollback { + pane_id, + client_id, + get_full_scrollback, + response_channel, + } => { + let mut pane_contents: Option<PaneContents> = None; + 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, None)); + break; + } + } + // Send response back through channel + let response = match pane_contents { + Some(contents) => PaneScrollbackResponse::Ok(contents), + None => { + log::warn!( + "Plugin requested scrollback for pane {:?} but pane was not found", + pane_id + ); + PaneScrollbackResponse::Err(format!("Pane {:?} not found", pane_id)) + }, + }; + if let Err(_) = response_channel.send(response) { + // the plugin likely timed out and dropped the receiver + log::debug!( + "Plugin timed out before pane scrollback response was sent for pane {:?}", + pane_id + ); + } + }, + ScreenInstruction::ScrollUp( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.scroll_active_terminal_up(client_id) + ); + screen.render(None)?; + }, + ScreenInstruction::MovePane( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.move_active_pane(client_id) + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::MovePaneBackwards( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.move_active_pane_backwards(client_id) + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::MovePaneDown( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.move_active_pane_down(client_id) + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::MovePaneUp( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.move_active_pane_up(client_id) + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::MovePaneRight( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.move_active_pane_right(client_id) + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::MovePaneLeft( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.move_active_pane_left(client_id) + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::ScrollUpAt( + point, + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .handle_scrollwheel_up(&point, 3, client_id), ? + ); + screen.render(None)?; + }, + ScreenInstruction::ScrollDown( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.scroll_active_terminal_down(client_id), ? + ); + screen.render(None)?; + }, + ScreenInstruction::ScrollDownAt( + point, + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .handle_scrollwheel_down(&point, 3, client_id), ? + ); + screen.render(None)?; + }, + ScreenInstruction::ScrollToBottom( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .scroll_active_terminal_to_bottom(client_id), ? + ); + screen.render(None)?; + }, + ScreenInstruction::ScrollToTop( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .scroll_active_terminal_to_top(client_id), ? + ); + screen.render(None)?; + }, + ScreenInstruction::PageScrollUp( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .scroll_active_terminal_up_page(client_id) + ); + screen.render(None)?; + }, + ScreenInstruction::PageScrollDown( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .scroll_active_terminal_down_page(client_id), ? + ); + screen.render(None)?; + }, + ScreenInstruction::HalfPageScrollUp( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .scroll_active_terminal_up_half_page(client_id) + ); + screen.render(None)?; + }, + ScreenInstruction::HalfPageScrollDown( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .scroll_active_terminal_down_half_page(client_id), ? + ); + screen.render(None)?; + }, + ScreenInstruction::ClearScroll(client_id) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .clear_active_terminal_scroll(client_id), ? + ); + screen.render(None)?; + }, + ScreenInstruction::CloseFocusedPane(client_id, completion_tx) => { + let old_pane_id = screen.get_active_pane_id(&client_id); + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.close_focused_pane(client_id, completion_tx), ? + ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + screen.report_key_passthrough_state(client_id, old, new); + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::SetSelectable(pid, selectable) => { + let all_tabs = screen.get_tabs_mut(); + let mut found_plugin = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pid) { + tab.set_pane_selectable(pid, selectable); + found_plugin = true; + break; + } + } + if !found_plugin { + pending_events_waiting_for_tab + .push(ScreenInstruction::SetSelectable(pid, selectable)); + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::ShowPluginCursor(pid, client_id, cursor_position) => { + let all_tabs = screen.get_tabs_mut(); + let mut found_plugin = false; + for tab in all_tabs.values_mut() { + if tab.has_plugin(pid) { + tab.show_plugin_cursor(pid, client_id, cursor_position); + found_plugin = true; + break; + } + } + if !found_plugin { + pending_events_waiting_for_tab.push(ScreenInstruction::ShowPluginCursor( + pid, + client_id, + cursor_position, + )); + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::SetMouseSelectionSupport(pid, selection_support) => { + let all_tabs = screen.get_tabs_mut(); + let mut found_plugin = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pid) { + tab.set_mouse_selection_support(pid, selection_support); + found_plugin = true; + break; + } + } + if !found_plugin { + pending_events_waiting_for_tab.push( + ScreenInstruction::SetMouseSelectionSupport(pid, selection_support), + ); + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::ClosePane( + id, + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + exit_status, + ) => { + match client_id { + Some(client_id) => { + active_tab!(screen, client_id, |tab: &mut Tab| tab.close_pane( + id, + false, + exit_status + )); + }, + None => { + let mut found = false; + for tab in screen.tabs.values_mut() { + if tab.get_all_pane_ids().contains(&id) { + tab.close_pane(id, false, exit_status); + found = true; + break; + } + } + if !found { + pending_events_waiting_for_pane + .entry(id) + .or_default() + .push(ScreenInstruction::ClosePane(id, None, None, exit_status)); + } + }, + } + + screen.clear_nested_guest(id); + + // Clean up PTY-side resources (async reader task, child PID mapping, + // terminal_id_to_raw_fd entry). This is needed because the natural + // child exit path (quit_cb) only sends ScreenInstruction::ClosePane + // and never sends PtyInstruction::ClosePane. The handler in Pty is + // idempotent, so this is safe even if ClosePane was already sent. + let _ = screen + .bus + .senders + .send_to_pty(PtyInstruction::ClosePane(id, None)); + + screen.log_and_report_session_state().non_fatal(); + screen.retain_only_existing_panes_in_pane_groups(); + }, + ScreenInstruction::HoldPane(id, exit_status, run_command) => { + let is_first_run = false; + let mut found = false; + for tab in screen.tabs.values_mut() { + if tab.get_all_pane_ids().contains(&id) { + tab.hold_pane(id, exit_status, is_first_run, run_command.clone()); + found = true; + break; + } + } + if !found { + pending_events_waiting_for_pane + .entry(id) + .or_default() + .push(ScreenInstruction::HoldPane(id, exit_status, run_command)); + } + screen.log_and_report_session_state()?; + }, + ScreenInstruction::UpdatePaneName( + c, + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.update_active_pane_name(c, client_id), ? + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::UndoRenamePane( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.undo_active_rename_pane(client_id), ? + ); + screen.render(None)?; + }, + ScreenInstruction::ToggleActiveTerminalFullscreen( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .toggle_active_pane_fullscreen(client_id) + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::ToggleActiveTerminalNoUiFullscreen(client_id, _completion_tx) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .toggle_active_pane_no_ui_fullscreen(client_id) + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::TogglePaneFrames( + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.pane_frame_style = match screen.pane_frame_style { + PaneFrameStyle::Full => PaneFrameStyle::Titles, + PaneFrameStyle::Titles => PaneFrameStyle::None, + PaneFrameStyle::None => PaneFrameStyle::Full, + }; + for tab in screen.tabs.values_mut() { + tab.set_pane_frames(screen.pane_frame_style); + tab.update_input_modes()?; + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::SetPaneFrameStyle(pane_frame_style, _completion_tx) => { + screen.pane_frame_style = pane_frame_style; + for tab in screen.tabs.values_mut() { + tab.set_pane_frames(screen.pane_frame_style); + tab.update_input_modes()?; + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::SwitchTabNext( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.switch_tab_next(None, true, client_id)?; + screen.render(None)?; + }, + ScreenInstruction::SwitchTabPrev( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.switch_tab_prev(None, true, client_id)?; + screen.render(None)?; + }, + ScreenInstruction::CloseTab( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.close_tab(client_id)?; + screen.render(None)?; + }, + ScreenInstruction::NewTab( + cwd, + default_shell, + layout, + floating_panes_layout, + tab_name, + (swap_tiled_layouts, swap_floating_layouts), + initial_panes, + block_on_first_terminal, + should_change_focus_to_new_tab, + (client_id, is_web_client), + completion_tx, + ) => { + let tab_index = screen.get_new_tab_id(); + pending_tab_ids.insert(tab_index); + let client_id_for_new_tab = if should_change_focus_to_new_tab { + Some(client_id) + } else { + None + }; + let resolved_swap_layouts = ( + swap_tiled_layouts + .unwrap_or_else(|| screen.default_layout.swap_tiled_layouts.clone()), + swap_floating_layouts + .unwrap_or_else(|| screen.default_layout.swap_floating_layouts.clone()), + ); + screen.new_tab( + tab_index, + resolved_swap_layouts, + tab_name.clone(), + client_id_for_new_tab, + )?; + screen + .bus + .senders + .send_to_plugin(PluginInstruction::NewTab( + cwd, + default_shell, + layout, + floating_panes_layout, + tab_index, + initial_panes, + block_on_first_terminal, + should_change_focus_to_new_tab, + (client_id, is_web_client), + completion_tx, + ))?; + }, + ScreenInstruction::ApplyLayout( + layout, + floating_panes_layout, + new_pane_pids, + new_floating_pane_pids, + new_plugin_ids, + tab_id, + should_change_focus_to_new_tab, + (client_id, is_web_client), + mut completion_tx, + blocking_terminal, + ) => { + log::info!( + "ScreenInstruction::ApplyLayout: applying layout for tab {}", + tab_id + ); + // tab_id is a stable identifier from NewTab instruction + if let Some(first_terminal_pane) = new_pane_pids.iter().next() { + completion_tx + .as_mut() + .map(|c| c.set_affected_pane_id(PaneId::Terminal(first_terminal_pane.0))); + } else if let Some(plugin_id) = + new_plugin_ids.values().next().and_then(|v| v.first()) + { + completion_tx + .as_mut() + .map(|c| c.set_affected_pane_id(PaneId::Plugin(*plugin_id))); + } + // Set the affected tab ID for plugin API return value + completion_tx + .as_mut() + .map(|c| c.set_affected_tab_id(tab_id)); + screen.apply_layout( + layout, + floating_panes_layout, + new_pane_pids.clone(), + new_floating_pane_pids, + new_plugin_ids.clone(), + tab_id, + should_change_focus_to_new_tab, + (client_id, is_web_client), + blocking_terminal, + )?; + pending_tab_ids.remove(&tab_id); + if pending_tab_ids.is_empty() { + for (tab_index, client_id) in pending_tab_switches.drain() { + screen.go_to_tab(tab_index as usize + 1, client_id)?; + } + if should_change_focus_to_new_tab { + // Convert ID → position for go_to_tab (which expects 1-based position) + if let Some(tab_position) = screen.get_tab_position_by_id(tab_id) { + screen.go_to_tab(tab_position + 1, client_id)?; + } + } + } else if should_change_focus_to_new_tab { + let client_id_to_switch = if screen.active_tab_ids.contains_key(&client_id) { + Some(client_id) + } else { + screen.active_tab_ids.keys().next().copied() + }; + if let Some(client_id_to_switch) = client_id_to_switch { + // Convert ID → position for pending_tab_switches (which stores positions) + if let Some(tab_position) = screen.get_tab_position_by_id(tab_id) { + pending_tab_switches.insert((tab_position, client_id_to_switch)); + } + } + } + + for plugin_ids in new_plugin_ids.values() { + for plugin_id in plugin_ids { + if let Some(loading_indication) = + plugin_loading_message_cache.remove(plugin_id) + { + screen.update_plugin_loading_stage(*plugin_id, loading_indication); + screen.render(None)?; + } + screen.render_blocker.register_blocking_plugin(*plugin_id); + } + } + + for event in pending_events_waiting_for_client.drain(..) { + screen.bus.senders.send_to_screen(event).non_fatal(); + } + + for event in pending_events_waiting_for_tab.drain(..) { + screen.bus.senders.send_to_screen(event).non_fatal(); + } + + screen.render(None)?; + // we do this here in order to recover from a race condition on app start + // that sometimes causes Zellij to think the terminal window is a different size + // than it actually is - here, we query the client for its terminal size after + // we've finished the setup and handle it as we handle a normal resize, + // while this can affect other instances of a layout being applied, the query is + // very short and cheap and shouldn't cause any trouble + if let Some(os_input) = &mut screen.bus.os_input { + for (client_id, _is_web_client) in screen.connected_clients.borrow().iter() { + log::info!( + "ApplyLayout: sending QueryTerminalSize to client {}", + client_id + ); + let _ = os_input + .send_to_client(*client_id, ServerToClientMsg::QueryTerminalSize); + } + } + }, + ScreenInstruction::GoToTab( + tab_index, + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + let client_id_to_switch = if client_id.is_none() { + None + } else if screen + .active_tab_ids + .contains_key(&client_id.expect("This is checked above")) + { + client_id + } else { + screen.active_tab_ids.keys().next().copied() + }; + match client_id_to_switch { + // we must make sure pending_tab_ids is empty because otherwise we cannot be + // sure this instruction is applied at the right time (eg. we might have a + // pending tab that will become not-pending after this instruction and change + // the client focus, which should have happened before this instruction and not + // after) + Some(client_id) if pending_tab_ids.is_empty() => { + screen.go_to_tab(tab_index as usize, client_id)?; + screen.render(None)?; + }, + _ => { + if let Some(client_id) = client_id { + pending_tab_switches.insert((tab_index as usize, client_id)); + } + }, + } + }, + ScreenInstruction::GoToTabName( + tab_name, + default_shell, + create, + client_id, + mut completion_tx, + ) => { + let swap_layouts = ( + screen.default_layout.swap_tiled_layouts.clone(), + screen.default_layout.swap_floating_layouts.clone(), + ); + let client_id = if client_id.is_none() { + None + } else if screen + .active_tab_ids + .contains_key(&client_id.expect("This is checked above")) + { + client_id + } else { + screen.active_tab_ids.keys().next().copied() + }; + if let Some(client_id) = client_id { + let is_web_client = screen + .connected_clients + .borrow() + .get(&client_id) + .copied() + .unwrap_or(false); + if let Ok(tab_exists) = screen.go_to_tab_name(tab_name.clone(), client_id) { + screen.render(None)?; + if tab_exists { + // Tab already exists - find its ID and set in completion + if let Some(existing_tab) = + screen.tabs.values().find(|t| t.name == tab_name) + { + completion_tx + .as_mut() + .map(|c| c.set_affected_tab_id(existing_tab.id)); + } + } + if create && !tab_exists { + let tab_index = screen.get_new_tab_id(); + let should_change_focus_to_new_tab = true; + screen.new_tab( + tab_index, + swap_layouts, + Some(tab_name), + Some(client_id), + )?; + screen + .bus + .senders + .send_to_plugin(PluginInstruction::NewTab( + None, + default_shell, + None, + vec![], + tab_index, + None, // initial_panes + false, // block_on_first_terminal + should_change_focus_to_new_tab, + (client_id, is_web_client), + completion_tx, + ))?; + continue; // so we don't get to the completion signalling below + } + } + } + }, + ScreenInstruction::UpdateTabName( + c, + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.update_active_tab_name(c, client_id)?; + screen.render(None)?; + }, + ScreenInstruction::UndoRenameTab( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.undo_active_rename_tab(client_id)?; + screen.render(None)?; + }, + ScreenInstruction::MoveTabLeft(client_id, completion_tx) => { + if pending_tab_ids.is_empty() { + screen.move_active_tab_to_left(client_id)?; + screen.render(None)?; + } else { + // Defer execution, forward completion_tx + pending_events_waiting_for_tab + .push(ScreenInstruction::MoveTabLeft(client_id, completion_tx)); + } + }, + ScreenInstruction::MoveTabRight(client_id, completion_tx) => { + if pending_tab_ids.is_empty() { + screen.move_active_tab_to_right(client_id)?; + screen.render(None)?; + } else { + // Defer execution, forward completion_tx + pending_events_waiting_for_tab + .push(ScreenInstruction::MoveTabRight(client_id, completion_tx)); + } + }, + ScreenInstruction::TerminalResize(new_size) => { + screen.resize_to_screen(new_size)?; + screen.log_and_report_session_state()?; // update tabs so that the ui indication will be send to the plugins + screen.render(None)?; + }, + ScreenInstruction::RecomputeTabSize(client_id, new_size) => { + screen.set_client_size(client_id, new_size); + let active_tab_id = screen.active_tab_ids.get(&client_id).copied(); + if let Some(tab_id) = active_tab_id { + screen.recompute_tab_size(tab_id)?; + screen.log_and_report_session_state()?; + screen.render(None)?; + } + }, + ScreenInstruction::TerminalPixelDimensions(pixel_dimensions) => { + screen.update_pixel_dimensions(pixel_dimensions); + }, + ScreenInstruction::TerminalBackgroundColor(background_color_instruction) => { + screen.update_terminal_background_color(background_color_instruction); + }, + ScreenInstruction::TerminalForegroundColor(background_color_instruction) => { + screen.update_terminal_foreground_color(background_color_instruction); + }, + ScreenInstruction::TerminalColorRegisters(color_registers) => { + screen.update_terminal_color_registers(color_registers); + }, + ScreenInstruction::SetKittyGraphicsSupport { + client_id, + supported, + } => { + screen.update_kitty_graphics_support(client_id, supported); + }, + ScreenInstruction::ForwardHostQuery { pane_id, query } => { + screen.forward_host_query(pane_id, query); + }, + ScreenInstruction::NestedSessionMessageFromPane { pane_id, message } => { + screen.handle_nested_session_message_from_pane(pane_id, message); + }, + ScreenInstruction::NestedGuestPingTick { pane_id } => { + screen.handle_nested_guest_ping_tick(pane_id); + }, + ScreenInstruction::NestedSessionMessageFromHost { client_id, message } => { + screen.handle_nested_session_message_from_host(client_id, message); + }, + ScreenInstruction::GuestModalChoice { + client_id, + pane_id, + outcome, + } => { + screen.handle_guest_modal_choice(client_id, pane_id, outcome); + }, + ScreenInstruction::ForwardedReplyFromHost { token, reply_bytes } => { + screen.handle_forwarded_reply_from_host(token, reply_bytes)?; + // The handler's replay of `pending_pty_input` mutates the + // grid. Without a render scheduling here the clients + // keep displaying the pre-reply frame + screen.render(None)?; + }, + ScreenInstruction::ResumePaneAfterForward { + pane_id, + reply_bytes, + } => { + screen.resume_pane_after_forward(pane_id, reply_bytes)?; + // Same rationale as ForwardedReplyFromHost above — the + // resume's replay paints the grid, so we must schedule + // a render before yielding back to the screen loop. + screen.render(None)?; + }, + ScreenInstruction::HostTerminalThemeChanged(mode) => { + screen.update_host_terminal_theme_mode(mode)?; + }, + ScreenInstruction::SetDarkTheme(mut completion_tx) => { + screen.apply_manual_host_terminal_theme_mode( + HostTerminalThemeMode::Dark, + &mut completion_tx, + )?; + }, + ScreenInstruction::SetLightTheme(mut completion_tx) => { + screen.apply_manual_host_terminal_theme_mode( + HostTerminalThemeMode::Light, + &mut completion_tx, + )?; + }, + ScreenInstruction::ToggleTheme(mut completion_tx) => { + let next = match screen.host_terminal_theme_mode { + Some(HostTerminalThemeMode::Dark) => HostTerminalThemeMode::Light, + Some(HostTerminalThemeMode::Light) => HostTerminalThemeMode::Dark, + // No prior mode: treat as Dark and toggle to Light. + None => HostTerminalThemeMode::Light, + }; + screen.apply_manual_host_terminal_theme_mode(next, &mut completion_tx)?; + }, + ScreenInstruction::ChangeMode( + input_mode, + base_mode, + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.change_mode(input_mode, base_mode, client_id)?; + screen.render(None)?; + }, + ScreenInstruction::ChangeModeForAllClients( + input_mode, + base_mode, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.change_mode_for_all_clients(input_mode, base_mode)?; + screen.render(None)?; + }, + ScreenInstruction::ToggleActiveSyncTab( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, _client_id: ClientId| tab.toggle_sync_panes_is_active() + ); + screen.log_and_report_session_state()?; + screen.render(None)?; + }, + ScreenInstruction::MouseEvent( + event, + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.handle_mouse_event(event, client_id); + }, + ScreenInstruction::Copy( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab!(screen, client_id, |tab: &mut Tab| tab + .copy_selection(client_id), ?); + screen.render(None)?; + }, + ScreenInstruction::Exit => { + break; + }, + ScreenInstruction::ToggleTab( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.toggle_tab(client_id)?; + screen.render(None)?; + }, + ScreenInstruction::AddClient( + client_id, + is_web_client, + client_size, + tab_position_to_focus, + pane_id_to_focus, + ) => { + screen.set_client_size(client_id, client_size); + screen.add_client(client_id, is_web_client)?; + let pane_id = pane_id_to_focus.map(|(pane_id, is_plugin)| { + if is_plugin { + PaneId::Plugin(pane_id) + } else { + PaneId::Terminal(pane_id) + } + }); + if let Some(pane_id) = pane_id { + screen.focus_pane_with_id(pane_id, true, false, client_id)?; + } else if let Some(tab_position_to_focus) = tab_position_to_focus { + screen.go_to_tab(tab_position_to_focus, client_id)?; + } + screen.show_guest_modals_for_new_client(client_id); + let focused_pane_id = screen.get_active_pane_id(&client_id); + if let Some(focused) = focused_pane_id { + screen.report_key_passthrough_state(client_id, focused, focused); + } + for event in pending_events_waiting_for_client.drain(..) { + screen.bus.senders.send_to_screen(event).non_fatal(); + } + screen.log_and_report_session_state()?; + + if is_web_client { + // we do this because + // we need to query the client for its size, and we must do it only after we've + // added it to our state. + // + // we have to do this specifically for web clients because the browser (as opposed + // to a traditional terminal) can only figure out its dimensions after we sent it relevant + // state (eg. font, which is controlled by our config and it needs to determine cell size) + if let Some(os_input) = &mut screen.bus.os_input { + let _ = os_input + .send_to_client(client_id, ServerToClientMsg::QueryTerminalSize); + } + } + + screen.render(None)?; + }, + ScreenInstruction::RemoveClient(client_id) => { + screen.remove_client(client_id).non_fatal(); + screen.log_and_report_session_state().non_fatal(); + screen.render(None).non_fatal(); + }, + ScreenInstruction::SuppressRenderUntilMobile(client_id) => { + screen.mobile_render_gate.gate(client_id); + screen + .bus + .senders + .send_to_background_jobs(BackgroundJob::MobileGateTimeout(client_id)) + .non_fatal(); + }, + ScreenInstruction::MobileSizeSettled(client_id) => { + screen.mobile_render_gate.record_settled_size(client_id); + screen + .bus + .senders + .send_to_plugin(PluginInstruction::ReleaseMobileRender(client_id)) + .non_fatal(); + if screen.try_lift_mobile_gate(client_id) { + screen.render(None)?; + } + }, + ScreenInstruction::ForceMobileUngate(client_id) => { + if screen.mobile_render_gate.is_gated(client_id) { + screen + .bus + .senders + .send_to_plugin(PluginInstruction::ReleaseMobileRender(client_id)) + .non_fatal(); + screen.mobile_render_gate.ungate(client_id); + screen.force_render_mobile_tab(client_id); + screen.render(None)?; + } + }, + ScreenInstruction::UpdateSearch( + c, + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.update_search_term(c, client_id), ? + ); + screen.render(None)?; + }, + ScreenInstruction::SearchDown( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.search_down(client_id) + ); + screen.render(None)?; + }, + ScreenInstruction::SearchUp( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.search_up(client_id) + ); + screen.render(None)?; + }, + ScreenInstruction::SearchToggleCaseSensitivity( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .toggle_search_case_sensitivity(client_id) + ); + screen.render(None)?; + }, + ScreenInstruction::SearchToggleWrap( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.toggle_search_wrap(client_id) + ); + screen.render(None)?; + }, + ScreenInstruction::SearchToggleWholeWord( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab.toggle_search_whole_words(client_id) + ); + screen.render(None)?; + }, + ScreenInstruction::AddRedPaneFrameColorOverride(pane_ids, error_text) => { + let all_tabs = screen.get_tabs_mut(); + for pane_id in pane_ids { + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.add_red_pane_frame_color_override(pane_id, error_text.clone()); + break; + } + } + } + screen.render(None)?; + }, + ScreenInstruction::AddHighlightPaneFrameColorOverride(pane_ids, error_text) => { + let all_tabs = screen.get_tabs_mut(); + for pane_id in pane_ids { + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.add_highlight_pane_frame_color_override( + pane_id, + error_text.clone(), + None, + ); + break; + } + } + } + screen.render(None)?; + }, + ScreenInstruction::ClearPaneFrameColorOverride(pane_ids) => { + let all_tabs = screen.get_tabs_mut(); + for pane_id in pane_ids { + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.clear_pane_frame_color_override(pane_id, None); + break; + } + } + } + screen.render(None)?; + }, + ScreenInstruction::SetTabBellFlash(tab_id, is_flashing) => { + if let Some(tab) = screen.tabs.values_mut().find(|t| t.id == tab_id) { + tab.tab_bell_flash = is_flashing; + tab.clear_tab_bell_ring(); + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::PreviousSwapLayout( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, _client_id: ClientId| tab.previous_swap_layout(), + ? + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::NextSwapLayout( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, _client_id: ClientId| tab.next_swap_layout(), + ? + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::OverrideLayout( + cwd, + default_shell, + mut tab_layouts, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + apply_only_to_focused_tab, + client_id, + completion_tx, + ) => { + // 1. Determine which tabs to close (exist but not in layout) + let existing_tab_indices: HashSet<usize> = screen.tabs.keys().copied().collect(); + let layout_tab_indices: HashSet<usize> = + tab_layouts.iter().map(|tl| tl.tab_index).collect(); + let tabs_to_close: Vec<usize> = existing_tab_indices + .difference(&layout_tab_indices) + .copied() + .collect(); + + // 2. Process each tab layout + let mut processed_tab_layouts = Vec::new(); + + if apply_only_to_focused_tab { + match screen.get_active_tab_mut(client_id) { + Ok(active_tab) => { + if tab_layouts.is_empty() { + log::error!("No tab layouts found, cannot override."); + continue; + } + let mut tab_layout_info = tab_layouts.remove(0); + tab_layout_info.tab_index = active_tab.id; + // Set the tab name if provided + if let Some(name) = tab_layout_info.tab_name.take() { + active_tab.name = name; + } + + // Find already-running panes for this tab + let (tiled_to_ignore, floating_indices) = find_already_running_panes( + &tab_layout_info.tiled_layout, + &tab_layout_info.floating_layouts, + active_tab, + ); + + // Mark run instructions to ignore (prevents re-spawning) + for run_instruction in tiled_to_ignore { + tab_layout_info + .tiled_layout + .ignore_run_instruction(run_instruction); + } + + for idx in floating_indices { + tab_layout_info + .floating_layouts + .get_mut(idx) + .map(|f| f.already_running = true); + } + + processed_tab_layouts.push(tab_layout_info); + }, + Err(e) => { + log::error!("Failed to override layout of active tab: {}", e); + }, + } + } else { + for mut tab_layout_info in tab_layouts { + // Get the tab by index + let tab = match screen.tabs.get_mut(&tab_layout_info.tab_index) { + Some(t) => t, + None => { + // no corresponding tab exists, we'll create it + processed_tab_layouts.push(tab_layout_info); + continue; + }, + }; + + // Set the tab name if provided + if let Some(name) = tab_layout_info.tab_name.take() { + tab.name = name; + } + + // Find already-running panes for this tab + let (tiled_to_ignore, floating_indices) = find_already_running_panes( + &tab_layout_info.tiled_layout, + &tab_layout_info.floating_layouts, + tab, + ); + + // Mark run instructions to ignore (prevents re-spawning) + for run_instruction in tiled_to_ignore { + tab_layout_info + .tiled_layout + .ignore_run_instruction(run_instruction); + } + + for idx in floating_indices { + tab_layout_info + .floating_layouts + .get_mut(idx) + .map(|f| f.already_running = true); + } + + processed_tab_layouts.push(tab_layout_info); + } + } + + // 3. Send to plugin thread with all tab layouts + screen + .bus + .senders + .send_to_plugin(PluginInstruction::OverrideLayout( + cwd, + default_shell, + processed_tab_layouts, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + client_id, + completion_tx, + ))?; + + // 4. Close tabs that aren't in the layout + if !apply_only_to_focused_tab { + for tab_index in tabs_to_close { + screen.close_tab_by_id(tab_index)?; + } + } + }, + ScreenInstruction::OverrideLayoutComplete( + tab_results, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + client_id, + completion_tx, + ) => { + // Process each tab result + for tab_result in tab_results { + if let Some(tab) = screen.tabs.get_mut(&tab_result.tab_index) { + if let Err(e) = tab.override_layout( + tab_result.tiled_layout, + tab_result.floating_layouts, + tab_result.swap_tiled_layouts.clone(), + tab_result.swap_floating_layouts.clone(), + tab_result.new_terminal_pids, + tab_result.new_floating_pane_pids, + tab_result.plugin_ids, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + client_id, + None, + ) { + log::error!( + "Failed to override layout for tab {}: {:?}", + tab_result.tab_index, + e + ); + } + } else { + // Tab doesn't exist - create it + let swap_layouts = ( + tab_result.swap_tiled_layouts.clone().unwrap_or_default(), + tab_result.swap_floating_layouts.clone().unwrap_or_default(), + ); + if let Err(e) = screen.new_tab( + tab_result.tab_index, + swap_layouts, + tab_result.tab_name.clone(), + None, + ) { + log::error!( + "Failed to create new tab {} during override completion: {:?}", + tab_result.tab_index, + e + ); + continue; + } + + // Now override the newly created tab's layout + if let Some(tab) = screen.tabs.get_mut(&tab_result.tab_index) { + if let Err(e) = tab.override_layout( + tab_result.tiled_layout, + tab_result.floating_layouts, + tab_result.swap_tiled_layouts, + tab_result.swap_floating_layouts, + tab_result.new_terminal_pids, + tab_result.new_floating_pane_pids, + tab_result.plugin_ids, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + client_id, + None, + ) { + log::error!( + "Failed to override layout for new tab {}: {:?}", + tab_result.tab_index, + e + ); + } + } + } + } + + for event in pending_events_waiting_for_client.drain(..) { + screen.bus.senders.send_to_screen(event).non_fatal(); + } + + for event in pending_events_waiting_for_tab.drain(..) { + screen.bus.senders.send_to_screen(event).non_fatal(); + } + + // Single render and log after all tabs are updated (performance optimization) + screen.log_and_report_session_state()?; + let _ = screen.render(None); + drop(completion_tx); // action ends here, notify the action initiator + }, + ScreenInstruction::QueryTabNames(client_id, completion_tx) => { + let tab_names = screen + .get_tabs_mut() + .values() + .map(|tab| tab.name.clone()) + .collect::<Vec<String>>(); + screen.bus.senders.send_to_server(ServerInstruction::Log( + tab_names, + client_id, + completion_tx, + ))?; + }, + ScreenInstruction::NewTiledPluginPane( + run_plugin, + pane_title, + skip_cache, + cwd, + client_id, + completion_tx, + explicit_tab_id, + no_focus, + ) => { + let tab_index = explicit_tab_id + .unwrap_or_else(|| *screen.active_tab_ids.values().next().unwrap_or(&1)); + let size = Size::default(); + let should_float = Some(false); + let should_be_opened_in_place = false; + let should_focus_plugin = if no_focus { Some(false) } else { None }; + screen + .bus + .senders + .send_to_pty(PtyInstruction::FillPluginCwd( + should_float, + should_be_opened_in_place, + false, // close_replaced_pane + pane_title, + run_plugin, + tab_index, + None, + client_id, + size, + skip_cache, + cwd, + should_focus_plugin, + None, + completion_tx, + ))?; + }, + ScreenInstruction::NewFloatingPluginPane( + run_plugin, + pane_title, + skip_cache, + cwd, + floating_pane_coordinates, + client_id, + completion_tx, + explicit_tab_id, + no_focus, + ) => { + let resolved_tab_index = + explicit_tab_id.or_else(|| screen.active_tab_ids.values().next().copied()); + match resolved_tab_index { + Some(tab_index) => { + let size = Size::default(); + let should_float = Some(true); + let should_be_opened_in_place = false; + let should_focus_plugin = if no_focus { Some(false) } else { None }; + screen + .bus + .senders + .send_to_pty(PtyInstruction::FillPluginCwd( + should_float, + should_be_opened_in_place, + false, // close_replaced_pane + pane_title, + run_plugin, + tab_index, + None, + client_id, + size, + skip_cache, + cwd, + should_focus_plugin, + floating_pane_coordinates, + completion_tx, + ))?; + }, + None => { + log::error!( + "Could not find an active tab - is there at least 1 connected user?" + ); + }, + } + }, + ScreenInstruction::NewInPlacePluginPane( + run_plugin, + pane_title, + pane_id_to_replace, + skip_cache, + close_replaced_pane, + client_id, + completion_tx, + explicit_tab_id, + no_focus, + ) => { + let resolved_tab_index = + explicit_tab_id.or_else(|| screen.active_tab_ids.values().next().copied()); + match resolved_tab_index { + Some(tab_index) => { + let size = Size::default(); + let should_float = None; + let should_be_in_place = true; + let should_focus_plugin = if no_focus { Some(false) } else { None }; + screen + .bus + .senders + .send_to_pty(PtyInstruction::FillPluginCwd( + should_float, + should_be_in_place, + close_replaced_pane, + pane_title, + run_plugin, + tab_index, + Some(pane_id_to_replace), + client_id, + size, + skip_cache, + None, + should_focus_plugin, + None, + completion_tx, + ))?; + }, + None => { + log::error!( + "Could not find an active tab - is there at least 1 connected user?" + ); + }, + } + }, + ScreenInstruction::StartOrReloadPluginPane(run_plugin, pane_title, completion_tx) => { + let tab_index = screen.active_tab_ids.values().next().unwrap_or(&1); + let size = Size::default(); + let should_float = Some(false); + + screen + .bus + .senders + .send_to_plugin(PluginInstruction::Reload( + should_float, + pane_title, + run_plugin, + *tab_index, + size, + completion_tx, + ))?; + }, + ScreenInstruction::AddPlugin( + should_float, + should_be_in_place, + close_replaced_pane, + run_plugin_or_alias, + pane_title, + tab_index, + plugin_id, + pane_id_to_replace, + cwd, + start_suppressed, + floating_pane_coordinates, + should_focus_plugin, + client_id, + mut completion_tx, + ) => { + let mut new_pane_placement = NewPanePlacement::default(); + let maybe_should_float = should_float; + let should_be_tiled = maybe_should_float.map(|f| !f).unwrap_or(false); + let should_float = maybe_should_float.unwrap_or(false); + if floating_pane_coordinates.is_some() || should_float { + new_pane_placement = NewPanePlacement::with_floating_pane_coordinates( + floating_pane_coordinates.clone(), + ); + } + if should_be_tiled { + new_pane_placement = NewPanePlacement::Tiled { + direction: None, + borderless: None, + }; + } + if should_be_in_place { + new_pane_placement = NewPanePlacement::with_pane_id_to_replace( + pane_id_to_replace.map(|id| id.into()), + close_replaced_pane, + ); + } + if screen.active_tab_ids.is_empty() && tab_index.is_none() { + pending_events_waiting_for_client.push(ScreenInstruction::AddPlugin( + maybe_should_float, + should_be_in_place, + close_replaced_pane, + run_plugin_or_alias, + pane_title, + tab_index, + plugin_id, + pane_id_to_replace, + cwd, + start_suppressed, + floating_pane_coordinates, + should_focus_plugin, + client_id, + completion_tx, + )); + continue; + } + let pane_title = pane_title.unwrap_or_else(|| { + format!( + "({}) - {}", + cwd.map(|cwd| cwd.display().to_string()) + .unwrap_or(".".to_owned()), + run_plugin_or_alias.location_string() + ) + }); + let run_plugin = Run::Plugin(run_plugin_or_alias); + + // Set affected pane ID for CLI client output + if let Some(ref mut completion) = completion_tx { + completion.set_affected_pane_id(PaneId::Plugin(plugin_id)); + } + + if should_be_in_place { + if let Some(pane_id_to_replace) = pane_id_to_replace { + let client_tab_index_or_pane_id = + ClientTabIndexOrPaneId::PaneId(pane_id_to_replace); + screen.replace_pane( + PaneId::Plugin(plugin_id), + None, + Some(run_plugin), + Some(pane_title), + close_replaced_pane, + client_tab_index_or_pane_id, + )?; + } else if let Some(client_id) = client_id { + let client_tab_index_or_pane_id = + ClientTabIndexOrPaneId::ClientId(client_id); + screen.replace_pane( + PaneId::Plugin(plugin_id), + None, + Some(run_plugin), + Some(pane_title), + close_replaced_pane, + client_tab_index_or_pane_id, + )?; + } else { + log::error!("Must have pane id to replace or connected client_id if replacing a pane"); + } + } else if let Some(client_id) = client_id { + active_tab_and_connected_client_id!(screen, client_id, |active_tab: &mut Tab, _client_id: ClientId| { + active_tab.new_pane( + PaneId::Plugin(plugin_id), + Some(pane_title), + Some(run_plugin), + start_suppressed, + should_focus_plugin.unwrap_or(true), + new_pane_placement, + Some(client_id), + None, + ) + }, ?); + } else if let Some(active_tab) = + tab_index.and_then(|tab_index| screen.tabs.get_mut(&tab_index)) + { + active_tab.new_pane( + PaneId::Plugin(plugin_id), + Some(pane_title), + Some(run_plugin), + start_suppressed, + should_focus_plugin.unwrap_or(true), + new_pane_placement, + None, + None, + )?; + } else { + log::error!("Tab index not found: {:?}", tab_index); + } + if let Some(loading_indication) = plugin_loading_message_cache.remove(&plugin_id) { + screen.update_plugin_loading_stage(plugin_id, loading_indication); + screen.render(None)?; + } + screen.log_and_report_session_state()?; + }, + ScreenInstruction::UpdatePluginLoadingStage(pid, loading_indication) => { + if loading_indication.is_error() { + screen.ungate_clients_for_mobile_plugin(pid); + } + let found_plugin = + screen.update_plugin_loading_stage(pid, loading_indication.clone()); + if !found_plugin { + plugin_loading_message_cache.insert(pid, loading_indication); + } + screen.render(None)?; + }, + ScreenInstruction::StartPluginLoadingIndication(pid, loading_indication) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_plugin(pid) { + tab.start_plugin_loading_indication(pid, loading_indication); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::ProgressPluginLoadingOffset(pid) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_plugin(pid) { + tab.progress_plugin_loading_offset(pid); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::RequestStateUpdateForPlugins => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + tab.update_input_modes()?; + } + screen.log_and_report_session_state()?; + screen.render(None)?; + }, + ScreenInstruction::LaunchOrFocusPlugin( + run_plugin, + should_float, + move_to_focused_tab, + should_open_in_place, + close_replaced_pane, + pane_id_to_replace, + skip_cache, + client_id, + mut completion_tx, + explicit_tab_id, + ) => match pane_id_to_replace { + Some(pane_id_to_replace) if should_open_in_place => { + let resolved_tab_index = + explicit_tab_id.or_else(|| screen.active_tab_ids.values().next().copied()); + match resolved_tab_index { + Some(tab_index) => { + let size = Size::default(); + screen + .bus + .senders + .send_to_pty(PtyInstruction::FillPluginCwd( + Some(should_float), + should_open_in_place, + close_replaced_pane, + None, + run_plugin, + tab_index, + Some(pane_id_to_replace), + client_id, + size, + skip_cache, + None, + None, + None, + completion_tx, + ))?; + }, + None => { + log::error!( + "Could not find an active tab - is there at least 1 connected user?" + ); + }, + } + }, + _ => { + let client_id = if screen.active_tab_ids.contains_key(&client_id) { + Some(client_id) + } else { + screen.get_first_client_id() + }; + let client_id_and_focused_tab = client_id.and_then(|client_id| { + screen + .active_tab_ids + .get(&client_id) + .map(|tab_index| (*tab_index, client_id)) + }); + let resolved_tab_and_client = explicit_tab_id + .and_then(|tid| client_id.map(|cid| (tid, cid))) + .or(client_id_and_focused_tab); + match resolved_tab_and_client { + Some((tab_index, client_id)) => { + if screen.focus_plugin_pane( + &run_plugin, + should_float, + move_to_focused_tab, + should_open_in_place, + client_id, + &mut completion_tx, + )? { + screen.render(None)?; + screen.log_and_report_session_state()?; + } else { + screen + .bus + .senders + .send_to_pty(PtyInstruction::FillPluginCwd( + Some(should_float), + should_open_in_place, + close_replaced_pane, + None, + run_plugin, + tab_index, + None, + client_id, + Size::default(), + skip_cache, + None, + None, + None, + completion_tx, + ))?; + } + }, + None => { + log::error!("No connected clients found - cannot load or focus plugin") + }, + } + }, + }, + ScreenInstruction::LaunchPlugin( + run_plugin, + should_float, + should_open_in_place, + close_replaced_pane, + pane_id_to_replace, + skip_cache, + cwd, + client_id, + completion_tx, + explicit_tab_id, + no_focus, + ) => match pane_id_to_replace { + Some(pane_id_to_replace) => { + let resolved_tab_index = + explicit_tab_id.or_else(|| screen.active_tab_ids.values().next().copied()); + match resolved_tab_index { + Some(tab_index) => { + let size = Size::default(); + screen + .bus + .senders + .send_to_pty(PtyInstruction::FillPluginCwd( + Some(should_float), + should_open_in_place, + close_replaced_pane, + None, + run_plugin, + tab_index, + Some(pane_id_to_replace), + client_id, + size, + skip_cache, + cwd, + if no_focus { Some(false) } else { None }, + None, + completion_tx, + ))?; + }, + None => { + log::error!( + "Could not find an active tab - is there at least 1 connected user?" + ); + }, + } + }, + None => { + let client_id = if screen.active_tab_ids.contains_key(&client_id) { + Some(client_id) + } else { + screen.get_first_client_id() + }; + let client_id_and_focused_tab = client_id.and_then(|client_id| { + screen + .active_tab_ids + .get(&client_id) + .map(|tab_index| (*tab_index, client_id)) + }); + let resolved_tab_and_client = explicit_tab_id + .and_then(|tid| client_id.map(|cid| (tid, cid))) + .or(client_id_and_focused_tab); + match resolved_tab_and_client { + Some((tab_index, client_id)) => { + screen + .bus + .senders + .send_to_pty(PtyInstruction::FillPluginCwd( + Some(should_float), + should_open_in_place, + close_replaced_pane, + None, + run_plugin, + tab_index, + None, + client_id, + Size::default(), + skip_cache, + cwd, + if no_focus { Some(false) } else { None }, + None, + completion_tx, + ))?; + }, + None => { + log::error!("No connected clients found - cannot load or focus plugin") + }, + } + }, + }, + ScreenInstruction::SuppressPane(pane_id, client_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_non_suppressed_pane_with_pid(&pane_id) { + tab.suppress_pane(pane_id, Some(client_id)); + drop(screen.render(None)); + break; + } + } + screen.log_and_report_session_state()?; + }, + ScreenInstruction::UnsuppressPane(pane_id, should_float_if_hidden) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.unsuppress_pane(pane_id, should_float_if_hidden); + drop(screen.render(None)); + break; + } + } + screen.log_and_report_session_state()?; + }, + ScreenInstruction::UnsuppressOrExpandPane(pane_id, should_float_if_hidden) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.unsuppress_or_expand_pane(pane_id, should_float_if_hidden); + drop(screen.render(None)); + break; + } + } + screen.log_and_report_session_state()?; + }, + ScreenInstruction::FocusPaneWithId( + pane_id, + should_float_if_hidden, + should_be_in_place_if_hidden, + client_id, + mut completion_tx, + ) => { + let pane_exists = screen + .tabs + .iter() + .any(|(_, tab)| tab.has_pane_with_pid(&pane_id)); + if !pane_exists { + if let Some(c) = completion_tx.as_mut() { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } else { + let already_focused = screen + .get_active_pane_id(&client_id) + .map(|active| active == pane_id) + .unwrap_or(false); + if already_focused { + if let Some(c) = completion_tx.as_mut() { + c.set_exit_status(1); + c.set_error_message(format!("Pane {:?} is already focused", pane_id)); + } + } else { + screen.focus_pane_with_id( + pane_id, + should_float_if_hidden, + should_be_in_place_if_hidden, + client_id, + )?; + screen.clear_bell_for_pane_id(pane_id, client_id); + screen.log_and_report_session_state()?; + } + } + }, + ScreenInstruction::RenamePane( + pane_id, + new_name, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + match tab.rename_pane(new_name, pane_id) { + Ok(()) => drop(screen.render(None)), + Err(e) => log::error!("Failed to rename pane: {:?}", e), + } + break; + } + } + screen.log_and_report_session_state()?; + }, + ScreenInstruction::RenameActivePane(new_name, client_id, _completion_tx) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .rename_active_pane(new_name, client_id), + ? + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::RenameTab( + tab_index, + new_name, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + // tab_index here is 1-based user input representing display position + let tab_position = tab_index.saturating_sub(1); // Convert to 0-based + + match screen.get_tab_by_position_mut(tab_position) { + Some(tab) => { + tab.name = String::from_utf8_lossy(&new_name).to_string(); + }, + None => { + log::error!("Failed to find tab at position: {}", tab_position); + }, + } + screen.log_and_report_session_state()?; + }, + ScreenInstruction::GoToTabWithId(tab_id, client_id, _completion_tx) => { + let client_id_to_switch = client_id + .and_then(|cid| { + if screen.active_tab_ids.contains_key(&cid) { + Some(cid) + } else { + screen.active_tab_ids.keys().next().copied() + } + }) + .or_else(|| screen.active_tab_ids.keys().next().copied()); + + if let Some(client_id) = client_id_to_switch { + // Get the position from the ID + if let Some(tab_position) = screen.get_tab_position_by_id(tab_id) { + // switch_active_tab expects 0-based position + screen.switch_active_tab(tab_position, None, true, client_id)?; + screen.render(None)?; + + screen + .tab_history + .entry(client_id) + .or_insert_with(Vec::new) + .push(tab_id); + } else { + log::error!("Tab with ID {} not found", tab_id); + } + } + }, + ScreenInstruction::RenameTabWithId(tab_id, new_name, _completion_tx) => { + // Use get_tab_by_id_mut() helper method + if let Some(tab) = screen.get_tab_by_id_mut(tab_id) { + tab.name = String::from_utf8_lossy(&new_name).to_string(); + screen.log_and_report_session_state()?; + } else { + log::error!("Failed to find tab with ID: {}", tab_id); + } + }, + ScreenInstruction::CloseTabWithId(tab_id, _completion_tx) => { + if screen.get_tab_by_id(tab_id).is_some() { + screen.close_tab_by_id(tab_id).non_fatal(); + } else { + log::error!("Failed to find tab with ID: {}", tab_id); + } + }, + ScreenInstruction::BreakPanesToTabWithId { + pane_ids, + tab_id, + should_change_focus_to_target_tab, + client_id, + mut completion_tx, + } => { + // Verify tab exists + if screen.get_tab_by_id(tab_id).is_none() { + log::error!("Tab with ID {} not found", tab_id); + // Don't set affected_tab_id, it will remain None to signal failure + } else { + // break_multiple_panes_to_tab_with_index uses tab ID + screen.break_multiple_panes_to_tab_with_index( + pane_ids, + tab_id, + should_change_focus_to_target_tab, + client_id, + )?; + // Set affected tab ID (tab_id is the ID here) + completion_tx + .as_mut() + .map(|c| c.set_affected_tab_id(tab_id)); + let pane_group = screen.get_client_pane_group(&client_id); + if !pane_group.is_empty() { + let _ = screen.bus.senders.send_to_background_jobs( + BackgroundJob::HighlightPanesWithMessage( + pane_group.iter().copied().collect(), + "BROKEN OUT".to_owned(), + ), + ); + } + screen.clear_pane_group(&client_id); + } + }, + ScreenInstruction::RequestPluginPermissions(plugin_id, plugin_permission) => { + let all_tabs = screen.get_tabs_mut(); + let found = all_tabs.values_mut().any(|tab| { + if tab.has_plugin(plugin_id) { + tab.request_plugin_permissions(plugin_id, Some(plugin_permission.clone())); + true + } else { + false + } + }); + + if !found { + log::error!("PluginId '{}' not found - caching request", plugin_id); + pending_events_waiting_for_client.push( + ScreenInstruction::RequestPluginPermissions(plugin_id, plugin_permission), + ); + } + }, + ScreenInstruction::BreakPane( + default_shell, + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + let default_layout = screen.default_layout.clone(); + screen.break_pane(default_shell, default_layout, client_id)?; + }, + ScreenInstruction::BreakPaneRight( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.break_pane_to_new_tab(Direction::Right, client_id)?; + }, + ScreenInstruction::BreakPaneLeft( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.break_pane_to_new_tab(Direction::Left, client_id)?; + }, + ScreenInstruction::UpdateSessionInfos(new_session_infos, resurrectable_sessions) => { + screen.update_session_infos(new_session_infos, resurrectable_sessions)?; + }, + ScreenInstruction::UpdateAvailableLayouts(layouts, errors) => { + screen.update_available_layouts(layouts, errors); + }, + ScreenInstruction::ReplacePane( + new_pane_id, + hold_for_command, + pane_title, + invoked_with, + close_replaced_pane, + client_id_tab_index_or_pane_id, + mut completion_tx, + ) => { + completion_tx + .as_mut() + .map(|c| c.set_affected_pane_id(new_pane_id)); + screen.replace_pane( + new_pane_id, + hold_for_command, + invoked_with, + pane_title, + close_replaced_pane, + client_id_tab_index_or_pane_id, + )?; + + screen.log_and_report_session_state()?; + }, + ScreenInstruction::SerializeLayoutForResurrection => { + if screen.session_serialization { + screen.dump_layout_to_hd()?; + } + }, + ScreenInstruction::SaveSession(_client_id, completion_tx) => { + let err_context = || "Failed to save session"; + + screen.update_active_pane_ids(); + let pane_manifest = screen.generate_and_report_pane_state()?; + let tab_infos = screen.generate_and_report_tab_state()?; + + #[cfg(not(test))] + let (available_layouts, _layout_errors) = Layout::list_available_layouts( + screen.layout_dir.clone(), + &screen.default_layout_name, + ); + #[cfg(test)] + let available_layouts = vec![]; + + let creation_time = { + let sock_path = ZELLIJ_SOCK_DIR.join(&screen.session_name); + std::fs::metadata(&sock_path) + .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() + }; + let session_info = SessionInfo { + name: screen.session_name.clone(), + tabs: tab_infos, + panes: pane_manifest, + connected_clients: screen.active_tab_ids.keys().len(), + is_current_session: true, + available_layouts, + web_clients_allowed: screen.web_sharing.web_clients_allowed(), + web_client_count: screen + .connected_clients + .borrow() + .iter() + .filter(|(_client_id, is_web_client)| **is_web_client) + .count(), + plugins: Default::default(), + tab_history: screen.tab_history.clone(), + pane_history: screen + .pane_history + .iter() + .map(|(k, v)| (*k, v.iter().map(|v| (*v).into()).collect())) + .collect(), + creation_time, + }; + + let session_layout_metadata = if screen.session_serialization { + screen.get_layout_metadata(Some(screen.default_shell.clone()), None) + } else { + // Create empty metadata if serialization is disabled + SessionLayoutMetadata::new(screen.default_layout.clone()) + }; + + screen + .bus + .senders + .send_to_pty(PtyInstruction::SaveSessionToDisk { + session_name: screen.session_name.clone(), + session_info, + session_layout_metadata, + completion_tx, + }) + .with_context(err_context)?; + }, + ScreenInstruction::RenameSession( + name, + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + 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 { + let _ = os_input.send_to_client( + client_id, + ServerToClientMsg::LogError { + lines: vec![error_text.to_owned()], + }, + ); + } + } 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); + if let Some(os_input) = &mut screen.bus.os_input { + let _ = os_input.send_to_client( + client_id, + ServerToClientMsg::LogError { + lines: vec![error_text.to_owned()], + }, + ); + } + } else { + let err_context = || format!("Failed to rename session"); + let old_session_name = screen.session_name.clone(); + + // update state + screen.session_name = name.clone(); + screen.default_mode_info.session_name = Some(name.clone()); + for (_client_id, mode_info) in screen.mode_info.iter_mut() { + mode_info.session_name = Some(name.clone()); + } + for (_, tab) in screen.tabs.iter_mut() { + tab.rename_session(name.clone()).with_context(err_context)?; + } + + // rename socket file + let old_socket_file_path = ZELLIJ_SOCK_DIR.join(&old_session_name); + let new_socket_file_path = ZELLIJ_SOCK_DIR.join(&name); + if let Err(e) = std::fs::rename(old_socket_file_path, new_socket_file_path) { + log::error!("Failed to rename ipc socket: {:?}", e); + } + + // rename session_info folder (TODO: make this atomic, right now there is a + // chance background_jobs will re-create this folder before it knows the + // session was renamed) + let old_session_info_folder = + session_info_folder_for_session(&old_session_name); + let new_session_info_folder = session_info_folder_for_session(&name); + if let Err(e) = + std::fs::rename(old_session_info_folder, new_session_info_folder) + { + log::error!("Failed to rename session_info folder: {:?}", e); + } + + // report + screen + .log_and_report_session_state() + .with_context(err_context)?; + + // set the env variable + set_session_name(name.clone()); + let connected_client_ids: Vec<ClientId> = + screen.active_tab_ids.keys().copied().collect(); + for client_id in connected_client_ids { + if let Some(os_input) = &mut screen.bus.os_input { + let _ = os_input.send_to_client( + client_id, + ServerToClientMsg::RenamedSession { name: name.clone() }, + ); + } + } + } + }, + ScreenInstruction::Reconfigure { + client_id, + keybinds, + default_mode, + theme, + host_theme_dark, + host_theme_light, + simplified_ui, + default_shell, + pane_frame_style, + copy_to_clipboard, + copy_command, + copy_on_select, + auto_layout, + rounded_corners, + hide_session_name, + stacked_resize, + stacked_pane_list, + default_editor, + advanced_mouse_actions, + mouse_scroll_resize, + mouse_hover_effects, + visual_bell, + focus_follows_mouse, + mouse_click_through, + nested_session_handling, + } => { + screen.host_theme_dark_styling = host_theme_dark; + screen.host_theme_light_styling = host_theme_light; + screen + .reconfigure( + keybinds, + default_mode, + theme, + simplified_ui, + default_shell, + pane_frame_style, + copy_command, + copy_to_clipboard, + copy_on_select, + auto_layout, + rounded_corners, + hide_session_name, + stacked_resize, + stacked_pane_list, + default_editor, + advanced_mouse_actions, + mouse_scroll_resize, + mouse_hover_effects, + visual_bell, + focus_follows_mouse, + mouse_click_through, + nested_session_handling, + client_id, + ) + .non_fatal(); + }, + ScreenInstruction::RerunCommandPane(terminal_pane_id, completion_tx) => { + screen.rerun_command_pane_with_id(terminal_pane_id, completion_tx) + }, + ScreenInstruction::ResizePaneWithId(resize, pane_id) => { + screen.resize_pane_with_id(resize, pane_id) + }, + ScreenInstruction::EditScrollbackForPaneWithId(pane_id, completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.edit_scrollback_for_pane_with_id(pane_id, completion_tx) + .non_fatal(); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::WriteToPaneId(bytes, pane_id, _completion) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.write_to_pane_id(&None, bytes, false, pane_id, None, None) + .non_fatal(); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::Paste(bytes, pane_id, client_id, _completion) => { + match pane_id { + Some(pane_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.paste_to_pane_id(bytes, pane_id, _completion) + .non_fatal(); + break; + } + } + }, + None => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, _client_id: ClientId| { + tab.paste_to_active_terminal(bytes, client_id, _completion) + .non_fatal(); + } + ); + }, + } + screen.render(None)?; + }, + ScreenInstruction::SetPaneColor(pane_id, fg, bg, _completion) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.set_pane_color(pane_id, fg, bg).non_fatal(); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::WriteKeyToPaneId( + key_with_modifier, + bytes, + is_kitty, + pane_id, + _completion, + ) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.write_to_pane_id( + &key_with_modifier, + bytes, + is_kitty, + pane_id, + None, // client_id not needed for targeted write + None, // completion handled by instruction + ) + .non_fatal(); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::CopyTextToClipboard(text, plugin_id) => { + let plugin_pane_id = PaneId::Plugin(plugin_id); + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&plugin_pane_id) { + tab.copy_text_to_clipboard(&text) + .with_context(|| { + format!( + "failed to copy text to clipboard from plugin {}", + plugin_id + ) + }) + .non_fatal(); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::MovePaneWithPaneId(pane_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.move_pane(pane_id); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::MovePaneWithPaneIdInDirection(pane_id, direction) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + match direction { + Direction::Down => tab.move_pane_down(pane_id), + Direction::Up => tab.move_pane_up(pane_id), + Direction::Left => tab.move_pane_left(pane_id), + Direction::Right => tab.move_pane_right(pane_id), + } + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::ClearScreenForPaneId(pane_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.clear_screen_for_pane_id(pane_id); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::ScrollUpInPaneId(pane_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + if let PaneId::Terminal(terminal_pane_id) = pane_id { + tab.scroll_terminal_up(terminal_pane_id)?; + } else { + // this is because to do this with plugins, we need the client_id - + // which we do not have (yet?) in this context... + log::error!( + "Currently only terminal panes are supported for scrolling up" + ); + } + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::ScrollDownInPaneId(pane_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + if let PaneId::Terminal(terminal_pane_id) = pane_id { + tab.scroll_terminal_down(terminal_pane_id)?; + } else { + // this is because to do this with plugins, we need the client_id - + // which we do not have (yet?) in this context... + log::error!( + "Currently only terminal panes are supported for scrolling down" + ); + } + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::ScrollToTopInPaneId(pane_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + if let PaneId::Terminal(terminal_pane_id) = pane_id { + tab.scroll_terminal_to_top(terminal_pane_id); + } else { + // this is because to do this with plugins, we need the client_id - + // which we do not have (yet?) in this context... + log::error!( + "Currently only terminal panes are supported for scrolling to top" + ); + } + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::ScrollToBottomInPaneId(pane_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + if let PaneId::Terminal(terminal_pane_id) = pane_id { + tab.scroll_terminal_to_bottom(terminal_pane_id); + } else { + // this is because to do this with plugins, we need the client_id - + // which we do not have (yet?) in this context... + log::error!("Currently only terminal panes are supported for scrolling to bottom"); + } + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::PageScrollUpInPaneId(pane_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + if let PaneId::Terminal(terminal_pane_id) = pane_id { + tab.scroll_terminal_page_up(terminal_pane_id); + } else { + // this is because to do this with plugins, we need the client_id - + // which we do not have (yet?) in this context... + log::error!( + "Currently only terminal panes are supported for scrolling" + ); + } + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::PageScrollDownInPaneId(pane_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + if let PaneId::Terminal(terminal_pane_id) = pane_id { + tab.scroll_terminal_page_down(terminal_pane_id); + } else { + // this is because to do this with plugins, we need the client_id - + // which we do not have (yet?) in this context... + log::error!( + "Currently only terminal panes are supported for scrolling" + ); + } + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::TogglePaneIdFullscreen(pane_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.toggle_pane_fullscreen(pane_id); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::SetTabFit { + client_id, + tab_id, + fit, + } => { + let changed = match fit { + Some((pane_id, size)) => { + screen.set_tab_fit(client_id, tab_id, pane_id, size)?; + true + }, + None => screen.exit_fit_mode(client_id)?, + }; + if changed { + screen.render(None)?; + } + }, + ScreenInstruction::TogglePaneEmbedOrEjectForPaneId(pane_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.toggle_pane_embed_or_floating_for_pane_id(pane_id, None) + .non_fatal(); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::CloseTabWithIndex(tab_index) => { + // tab_index here means display position (0-based) per API convention + // Must map to stable ID for close_tab_by_id() + + if let Some(tab_id) = screen.get_tab_id_at_position(tab_index) { + screen.close_tab_by_id(tab_id).non_fatal(); + } else { + log::error!("Failed to find tab at position: {}", tab_index); + } + }, + ScreenInstruction::BreakPanesToNewTab { + pane_ids, + default_shell, + should_change_focus_to_new_tab, + new_tab_name, + client_id, + mut completion_tx, + } => { + let tab_id = screen.break_multiple_panes_to_new_tab( + pane_ids, + default_shell, + should_change_focus_to_new_tab, + new_tab_name, + client_id, + )?; + // Set affected tab ID for plugin API return value + completion_tx + .as_mut() + .map(|c| c.set_affected_tab_id(tab_id)); + // TODO: is this a race? + let pane_group = screen.get_client_pane_group(&client_id); + if !pane_group.is_empty() { + let _ = screen.bus.senders.send_to_background_jobs( + BackgroundJob::HighlightPanesWithMessage( + pane_group.iter().copied().collect(), + "BROKEN OUT".to_owned(), + ), + ); + } + screen.clear_pane_group(&client_id); + }, + ScreenInstruction::BreakPanesToTabWithIndex { + pane_ids, + tab_index, + should_change_focus_to_new_tab, + client_id, + mut completion_tx, + } => { + // tab_index is the target tab ID + screen.break_multiple_panes_to_tab_with_index( + pane_ids, + tab_index, + should_change_focus_to_new_tab, + client_id, + )?; + // Set affected tab ID (tab_index is the ID here) + completion_tx + .as_mut() + .map(|c| c.set_affected_tab_id(tab_index)); + let pane_group = screen.get_client_pane_group(&client_id); + if !pane_group.is_empty() { + let _ = screen.bus.senders.send_to_background_jobs( + BackgroundJob::HighlightPanesWithMessage( + pane_group.iter().copied().collect(), + "BROKEN OUT".to_owned(), + ), + ); + } + screen.clear_pane_group(&client_id); + }, + ScreenInstruction::TogglePanePinned( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.toggle_pane_pinned(client_id); + }, + ScreenInstruction::SetFloatingPanePinned(pane_id, should_be_pinned) => { + screen.set_floating_pane_pinned(pane_id, should_be_pinned); + }, + ScreenInstruction::StackPanes( + pane_ids_to_stack, + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + if let Some(last_pane_id) = screen.stack_panes(pane_ids_to_stack) { + let _ = screen.focus_pane_with_id(last_pane_id, false, false, client_id); + let _ = screen.render(None); + let pane_group = screen.get_client_pane_group(&client_id); + if !pane_group.is_empty() { + let _ = screen.bus.senders.send_to_background_jobs( + BackgroundJob::HighlightPanesWithMessage( + pane_group.iter().copied().collect(), + "STACKED".to_owned(), + ), + ); + } + screen.clear_pane_group(&client_id); + } + }, + ScreenInstruction::ChangeFloatingPanesCoordinates( + pane_ids_and_coordinates, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.change_floating_panes_coordinates(pane_ids_and_coordinates); + let _ = screen.render(None); + }, + ScreenInstruction::TogglePaneBorderless(pane_id, _completion_tx) => { + screen.toggle_pane_borderless(pane_id); + let _ = screen.render(None); + }, + ScreenInstruction::SetPaneBorderless(pane_id, borderless, _completion_tx) => { + screen.set_pane_borderless(pane_id, borderless); + let _ = screen.render(None); + }, + ScreenInstruction::GroupAndUngroupPanes( + pane_ids_to_group, + pane_ids_to_ungroup, + for_all_clients, + client_id, + ) => { + screen.group_and_ungroup_panes( + pane_ids_to_group, + pane_ids_to_ungroup, + for_all_clients, + client_id, + ); + let _ = screen.log_and_report_session_state(); + }, + ScreenInstruction::TogglePaneInGroup( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.toggle_pane_in_group(client_id).non_fatal(); + }, + ScreenInstruction::ToggleGroupMarking( + client_id, + _completion_tx, // the action ends here, dropping this will release anything + // waiting for it + ) => { + screen.toggle_group_marking(client_id).non_fatal(); + }, + ScreenInstruction::SessionSharingStatusChange(web_sharing) => { + if web_sharing { + screen.web_sharing = WebSharing::On; + } else { + screen.web_sharing = WebSharing::Off; + } + + for tab in screen.tabs.values_mut() { + tab.update_web_sharing(screen.web_sharing); + } + let _ = screen.log_and_report_session_state(); + let _ = screen.render(None); + }, + ScreenInstruction::HighlightAndUnhighlightPanes( + pane_ids_to_highlight, + pane_ids_to_unhighlight, + client_id, + ) => { + { + let all_tabs = screen.get_tabs_mut(); + for pane_id in pane_ids_to_highlight { + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.add_highlight_pane_frame_color_override( + pane_id, + None, + Some(client_id), + ); + } + } + } + for pane_id in pane_ids_to_unhighlight { + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.clear_pane_frame_color_override(pane_id, Some(client_id)); + } + } + } + screen.render(None)?; + } + let _ = screen.log_and_report_session_state(); + }, + ScreenInstruction::FloatMultiplePanes(pane_ids_to_float, client_id) => { + { + let all_tabs = screen.get_tabs_mut(); + let mut ejected_panes_in_group = vec![]; + for pane_id in pane_ids_to_float { + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + if !tab.pane_id_is_floating(&pane_id) { + ejected_panes_in_group.push(pane_id); + tab.toggle_pane_embed_or_floating_for_pane_id( + pane_id, + Some(client_id), + ) + .non_fatal(); + } + tab.show_floating_panes(); + } + } + } + screen.render(None)?; + if !ejected_panes_in_group.is_empty() { + let _ = screen.bus.senders.send_to_background_jobs( + BackgroundJob::HighlightPanesWithMessage( + ejected_panes_in_group, + "EJECTED".to_owned(), + ), + ); + } + } + let _ = screen.log_and_report_session_state(); + }, + ScreenInstruction::EmbedMultiplePanes(pane_ids_to_float, client_id) => { + { + let all_tabs = screen.get_tabs_mut(); + let mut embedded_panes_in_group = vec![]; + for pane_id in pane_ids_to_float { + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + if tab.pane_id_is_floating(&pane_id) { + embedded_panes_in_group.push(pane_id); + tab.toggle_pane_embed_or_floating_for_pane_id( + pane_id, + Some(client_id), + ) + .non_fatal(); + } + tab.hide_floating_panes(); + } + } + } + screen.render(None)?; + if !embedded_panes_in_group.is_empty() { + let _ = screen.bus.senders.send_to_background_jobs( + BackgroundJob::HighlightPanesWithMessage( + embedded_panes_in_group, + "EMBEDDED".to_owned(), + ), + ); + } + } + let _ = screen.log_and_report_session_state(); + }, + ScreenInstruction::InterceptKeyPresses(plugin_id, client_id) => { + keybind_intercepts.insert(client_id, plugin_id); + }, + ScreenInstruction::ClearKeyPressesIntercepts(client_id) => { + keybind_intercepts.remove(&client_id); + }, + ScreenInstruction::ReplacePaneWithExistingPane( + old_pane_id, + new_pane_id, + suppress_replaced_pane, + completion_tx, + ) => screen.replace_pane_with_existing_pane( + old_pane_id, + new_pane_id, + suppress_replaced_pane, + completion_tx, + ), + ScreenInstruction::AddWatcherClient(client_id, size) => { + screen + .add_watcher_client(client_id) + .context("failed to add watcher client")?; + screen.set_watcher_size(client_id, size); + screen.render(None)?; + }, + ScreenInstruction::RemoveWatcherClient(client_id) => { + screen.remove_watcher_client(client_id); + }, + ScreenInstruction::SetFollowedClient(client_id) => { + screen + .set_followed_client(client_id) + .context("failed to set followed client")?; + }, + ScreenInstruction::WatcherTerminalResize(client_id, size) => { + screen.set_watcher_size(client_id, size); + screen.render(None)?; + }, + ScreenInstruction::ClearMouseHelpText(client_id) => { + if let Ok(tab) = screen.get_active_tab_mut(client_id) { + tab.clear_mouse_help_text(client_id); + screen.render(None)?; + } + }, + ScreenInstruction::SetPluginRegexHighlights { + pane_id, + plugin_id, + highlights, + } => { + let style = screen.style.clone(); + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.set_plugin_regex_highlights_for_pane( + pane_id, plugin_id, highlights, &style, + ); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::ClearPluginHighlights { pane_id, plugin_id } => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.clear_plugin_highlights_for_pane(pane_id, plugin_id); + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::ClearAllPluginHighlights(plugin_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + tab.clear_all_plugin_highlights(plugin_id); + } + screen.render(None)?; + }, + ScreenInstruction::DesktopNotificationResponse(raw_bytes, client_id) => { + if let Some((terminal_id, app_wants_report, is_query, rewritten_bytes)) = + denormalize_notification_response(&raw_bytes) + { + let pane_id = PaneId::Terminal(terminal_id); + // Write response to the pane if the app expects it: + // capability query answers (q flag) or activation reports (r flag) + if app_wants_report || is_query { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.write_to_pane_id( + &None, + rewritten_bytes, + false, + pane_id, + None, + None, + ) + .non_fatal(); + break; + } + } + } + // Focus the pane on activation click (not on query responses) + if !is_query { + screen + .focus_pane_with_id(pane_id, false, false, client_id) + .non_fatal(); + } + screen.render(None)?; + screen.log_and_report_session_state()?; + } + }, + ScreenInstruction::SubscribeToPaneRenders { + client_id, + pane_ids, + scrollback, + ansi, + } => { + screen.subscribe_to_pane_renders(client_id, pane_ids, scrollback, ansi); + }, + ScreenInstruction::NotifyPaneClosedToSubscribers { pane_id } => { + screen.notify_pane_closed_to_subscribers(pane_id); + screen.clear_fit_for_closed_pane(pane_id.into())?; + }, + ScreenInstruction::PluginSubscribedToAnsiPaneContents(has_subscribers) => { + let previously_subscribed = screen.plugins_need_ansi_pane_contents; + screen.plugins_need_ansi_pane_contents = has_subscribers; + if has_subscribers && !previously_subscribed { + if let Err(e) = screen.render(None) { + log::error!("Failed to render after ANSI subscription enabled: {:?}", e); + } + } + }, + ScreenInstruction::UpdateBackgroundPluginSubscriptions( + plugin_id, + client_id, + subscriptions, + ) => { + if subscriptions.is_empty() { + screen + .background_plugin_subscriptions + .remove(&(plugin_id, client_id)); + } else { + screen + .background_plugin_subscriptions + .insert((plugin_id, client_id), subscriptions); + } + }, + ScreenInstruction::ClearHintTextCache => { + for tab in screen.tabs.values_mut() { + tab.clear_hint_text_cache(); + } + screen.render(None)?; + }, + ScreenInstruction::BroadcastModeUpdate(mode_info, target_client_id) => { + screen.broadcast_mode_update(mode_info, target_client_id)?; + }, + // Pane-targeting CLI handlers + ScreenInstruction::ScrollUpWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.scroll_up_by_pane_id(pane_id); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + }, + ScreenInstruction::ScrollDownWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.scroll_down_by_pane_id(pane_id).non_fatal(); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + }, + ScreenInstruction::ScrollToTopWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.scroll_to_top_by_pane_id(pane_id).non_fatal(); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + }, + ScreenInstruction::ScrollToBottomWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.scroll_to_bottom_by_pane_id(pane_id).non_fatal(); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + }, + ScreenInstruction::PageScrollUpWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.page_scroll_up_by_pane_id(pane_id); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + }, + ScreenInstruction::PageScrollDownWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.page_scroll_down_by_pane_id(pane_id).non_fatal(); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + }, + ScreenInstruction::HalfPageScrollUpWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.half_page_scroll_up_by_pane_id(pane_id); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + }, + ScreenInstruction::HalfPageScrollDownWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.half_page_scroll_down_by_pane_id(pane_id).non_fatal(); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + }, + ScreenInstruction::ResizeWithPaneId(pane_id, strategy, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.resize_by_pane_id(pane_id, strategy); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::MovePaneWithPaneIdCli(pane_id, direction, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.move_pane_by_pane_id(pane_id, direction); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::MovePaneBackwardsWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.move_pane_backwards_by_pane_id(pane_id); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::ClearScreenWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.clear_screen_by_pane_id(pane_id).non_fatal(); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + }, + ScreenInstruction::EditScrollbackWithPaneId(pane_id, ansi, completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + if ansi { + tab.edit_scrollback_raw_for_pane_with_id(pane_id, completion_tx) + .non_fatal(); + } else { + tab.edit_scrollback_for_pane_with_id(pane_id, completion_tx) + .non_fatal(); + } + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + } + screen.render(None)?; + }, + ScreenInstruction::ToggleFullscreenWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.toggle_fullscreen_by_pane_id(pane_id); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::ToggleNoUiFullscreenWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.toggle_no_ui_fullscreen_by_pane_id(pane_id); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::TogglePaneEmbedOrFloatingWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.toggle_pane_embed_or_floating_for_pane_id(pane_id, None) + .non_fatal(); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.log_and_report_session_state()?; + screen.render(None)?; + }, + ScreenInstruction::CloseFocusWithPaneId(pane_id, completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.close_pane_by_pane_id(pane_id, completion_tx) + .non_fatal(); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::RenamePaneWithPaneId(pane_id, name, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.rename_pane_by_pane_id(pane_id, name).non_fatal(); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::UndoRenamePaneWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.undo_rename_pane_by_pane_id(pane_id); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + }, + ScreenInstruction::TogglePanePinnedWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.toggle_pane_pinned_by_pane_id(pane_id); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + }, + // Tab-targeting CLI handlers + ScreenInstruction::UndoRenameTabWithTabId(tab_id, mut _completion_tx) => { + if let Some(tab) = screen.tabs.get_mut(&tab_id) { + if tab.name != tab.prev_name { + tab.name = tab.prev_name.clone(); + screen.log_and_report_session_state()?; + } + } else { + log::error!("Tab with id {} not found", tab_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Tab with id {} not found", tab_id)); + } + } + screen.render(None)?; + }, + ScreenInstruction::ToggleActiveSyncTabWithTabId(tab_id, mut _completion_tx) => { + if let Some(tab) = screen.tabs.get_mut(&tab_id) { + tab.toggle_sync_panes_is_active(); + } else { + log::error!("Tab with id {} not found", tab_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Tab with id {} not found", tab_id)); + } + } + screen.log_and_report_session_state()?; + screen.render(None)?; + }, + ScreenInstruction::ToggleFloatingPanesWithTabId( + tab_id, + default_shell, + mut completion_tx, + ) => { + if let Some(tab) = screen.tabs.get_mut(&tab_id) { + // Pass None as client_id so that if a new floating pane must be spawned, + // it targets this tab (via TabIndex) rather than the focused tab of some client. + tab.toggle_floating_panes(None, default_shell, completion_tx) + .non_fatal(); + } else { + log::error!("Tab with id {} not found", tab_id); + if let Some(ref mut c) = completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Tab with id {} not found", tab_id)); + } + drop(completion_tx); + } + screen.log_and_report_session_state()?; + screen.render(None)?; + }, + ScreenInstruction::PreviousSwapLayoutWithTabId(tab_id, mut _completion_tx) => { + if let Some(tab) = screen.tabs.get_mut(&tab_id) { + tab.previous_swap_layout().non_fatal(); + } else { + log::error!("Tab with id {} not found", tab_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Tab with id {} not found", tab_id)); + } + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::NextSwapLayoutWithTabId(tab_id, mut _completion_tx) => { + if let Some(tab) = screen.tabs.get_mut(&tab_id) { + tab.next_swap_layout().non_fatal(); + } else { + log::error!("Tab with id {} not found", tab_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Tab with id {} not found", tab_id)); + } + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, + ScreenInstruction::MoveTabWithTabId(tab_id, direction, _completion_tx) => { + if pending_tab_ids.is_empty() { + screen.move_tab_by_id(tab_id, direction)?; + screen.render(None)?; + } else { + pending_events_waiting_for_tab.push(ScreenInstruction::MoveTabWithTabId( + tab_id, + direction, + _completion_tx, + )); + } + }, + ScreenInstruction::EnterMobileMode(client_id, _completion_tx) => { + if let Err(e) = screen.enter_mobile_mode(client_id) { + screen.mobile_render_gate.ungate(client_id); + screen.render(None)?; + return Err(e); + } + screen.mobile_state.mark_auto_entered(client_id); + screen.render(None)?; + }, + ScreenInstruction::ExitMobileMode(client_id, _completion_tx) => { + screen.exit_mobile_mode(client_id)?; + screen.render(None)?; + }, + ScreenInstruction::ToggleMobileMode(client_id, _completion_tx) => { + screen.toggle_mobile_mode(client_id)?; + screen.render(None)?; + }, + ScreenInstruction::ReevaluateMobileMode { + client_id, + new_size, + mobile_layout, + threshold_cols, + threshold_rows, + } => { + screen.reevaluate_mobile_mode( + client_id, + new_size, + mobile_layout, + threshold_cols, + threshold_rows, + )?; + }, + ScreenInstruction::SetSoftKeyboard { client_id, on } => { + if let Some(os_input) = &screen.bus.os_input { + let _ = os_input + .send_to_client(client_id, ServerToClientMsg::SetSoftKeyboard { on }); + } + }, + ScreenInstruction::SetShadowFocus(client_id, pane_id) => { + screen.set_shadow_focus(client_id, pane_id)?; + }, + ScreenInstruction::FocusHostSession(client_id, _completion_tx) => { + let payload = nested_session::encode_payload(&NestedSessionMessage::FocusHost { + direction: None, + }); + let _ = screen.bus.senders.send_to_server( + ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload), + ); + }, + ScreenInstruction::FocusGuestSession(client_id, _completion_tx) => { + screen.focus_guest_session(client_id); + }, + ScreenInstruction::ToggleHostFullscreen(client_id, _completion_tx) => { + screen.own_fullscreen_requested = !screen.own_fullscreen_requested; + let fullscreen = screen.own_fullscreen_requested; + let payload = + nested_session::encode_payload(&NestedSessionMessage::ToggleHostFullscreen { + fullscreen, + }); + let _ = screen.bus.senders.send_to_server( + ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload), + ); + }, + } + } + Ok(()) +} + +#[path = "./unit/screen_tests.rs"] +#[cfg(test)] +mod screen_tests; |
